From 7756334cca3b3ac0c296dbf3da1882ee5499de2e Mon Sep 17 00:00:00 2001 From: Tal Gluck Date: Thu, 16 Jul 2026 12:38:14 +0200 Subject: [PATCH 01/11] CLI: include hand-written commands in shell completion (RND-12064) Shell completion was generated purely from the spec-derived command tree, so the hand-written groups' subcommands never tab-completed. Add a static HAND_WRITTEN_COMPLETIONS map (integration -> new/dev/publish/unpublish/tail/check, openapi -> publish) merged into the completion map before the scripts are emitted. --- scripts/generate-commands.ts | 34 +++++++++++++++++++++------------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/scripts/generate-commands.ts b/scripts/generate-commands.ts index cb9b23b4b..1d495b923 100644 --- a/scripts/generate-commands.ts +++ b/scripts/generate-commands.ts @@ -143,6 +143,19 @@ const EXCLUDE = new Set([ 'POST /integrations/{integrationName}/render', ]); +// Hand-written commands that live outside the spec-derived tree (registered by +// registerCustomCommands in api-commands.ts and the auth/completion commands in +// cli.ts). The generator can't see them, so shell completion would otherwise miss +// them and their subcommands. Keep this in sync with those files as hand-written +// commands change; the CLI has no runtime introspection to derive it from. +// '' → tokens that complete at the top level (the hand-written groups + auth). +// 'x' → subcommands that complete under the hand-written group `x`. +const HAND_WRITTEN_COMPLETIONS: Record = { + '': ['login', 'logout', 'auth', 'whoami', 'completion', 'integration', 'openapi', 'help'], + integration: ['new', 'dev', 'publish', 'unpublish', 'tail', 'check'], + openapi: ['publish'], +}; + // Top-level resources: those reachable directly as /{resource}/{id}. These are // the only resources eligible for the P1 scope-flag merge. const TIER1 = new Set([ @@ -1123,19 +1136,14 @@ function completionMap(commands: Command[]): Map { function generateCompletions(commands: Command[]): Record { const map = completionMap(commands); - // Always-available top-level extras (hand-written commands not in the generated tree). - for (const extra of [ - 'login', - 'logout', - 'auth', - 'whoami', - 'completion', - 'integration', - 'openapi', - 'help', - ]) { - if (!map.has('')) map.set('', []); - if (!map.get('')!.includes(extra)) map.get('')!.push(extra); + // Merge in the hand-written commands (top-level groups + their subcommands) + // so `gitbook integration `, `gitbook openapi `, etc. complete too. + for (const [key, tokens] of Object.entries(HAND_WRITTEN_COMPLETIONS)) { + if (!map.has(key)) map.set(key, []); + const arr = map.get(key)!; + for (const token of tokens) { + if (!arr.includes(token)) arr.push(token); + } } // bash: a `case`-based child lookup (portable to bash 3.2; no associative From 024f776ebc7b2d49d5505fa6ead6faa1fd6f1b6c Mon Sep 17 00:00:00 2001 From: Tal Gluck Date: Thu, 16 Jul 2026 12:38:14 +0200 Subject: [PATCH 02/11] CLI: nested, sorted command tree in --help + integrations pointer (RND-12047, RND-12053) - Append a "Command groups (nested)" section to every group's --help so nested subgroups (e.g. organizations sites) are discoverable without drilling in. Root uses a compact one-line-per-group layout; subgroup pages use an indented tree. Attached generically by walking the Commander tree at runtime. - Sort subcommands alphabetically (built-in list via configureHelp + the tree). - Point `gitbook integrations --help` at the singular `gitbook integration` group for the build/publish lifecycle (new/dev/publish/...). --- .changeset/cli-help-discoverability.md | 5 + packages/cli/src/cli.ts | 18 ++++ packages/cli/src/help-tree.ts | 139 +++++++++++++++++++++++++ 3 files changed, 162 insertions(+) create mode 100644 .changeset/cli-help-discoverability.md create mode 100644 packages/cli/src/help-tree.ts diff --git a/.changeset/cli-help-discoverability.md b/.changeset/cli-help-discoverability.md new file mode 100644 index 000000000..73bff2f86 --- /dev/null +++ b/.changeset/cli-help-discoverability.md @@ -0,0 +1,5 @@ +--- +'@gitbook/cli': patch +--- + +Improve command discoverability: `--help` now shows a nested "Command groups" tree so subgroups like `organizations sites` are visible without drilling in; shell completion now includes the hand-written `integration` and `openapi` subcommands; and `gitbook integrations --help` points to `gitbook integration` for the build/publish lifecycle commands. diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 7cf19fc3d..ad2cc66ce 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -32,9 +32,14 @@ import { authenticate, login, logout, whoami } from './remote'; import { withEnvironment } from './environments'; import { registerGeneratedCommands, COMPLETIONS } from './generated-commands'; import { registerCustomCommands } from './api-commands'; +import { installCommandTreeHelp } from './help-tree'; program.name('gitbook').description(packageJSON.description).version(packageJSON.version); +// List subcommands alphabetically in --help (both the built-in command list and +// the nested tree in help-tree.ts), rather than in registration order. +program.configureHelp({ sortSubcommands: true }); + program .command('login') .option('-e, --endpoint ', GITBOOK_DEFAULT_ENDPOINT) @@ -153,6 +158,19 @@ program registerGeneratedCommands(program); registerCustomCommands(program); +// The integration build/publish lifecycle (new/dev/publish/…) lives under the +// singular `integration` group, kept distinct from the spec-generated plural +// `integrations` group (raw API ops). People reasonably look under `integrations` +// first, so point them across from there. +const integrationsGroup = program.commands.find((c) => c.name() === 'integrations'); +integrationsGroup?.addHelpText( + 'after', + `\nTo create, run, or publish your own integration (new, dev, publish, …), see \`${program.name()} integration --help\`.`, +); + +// Reveal nested subgroups in `--help` (Commander shows only immediate children). +installCommandTreeHelp(program); + checkNodeVersion({ node: '>= 18' }, (error, result) => { if (error) { console.error(error); diff --git a/packages/cli/src/help-tree.ts b/packages/cli/src/help-tree.ts new file mode 100644 index 000000000..fe61c1ad8 --- /dev/null +++ b/packages/cli/src/help-tree.ts @@ -0,0 +1,139 @@ +import { Command, Help } from 'commander'; + +/** + * Nested command-tree help. + * + * Commander's default `--help` only lists a command's *immediate* children, so + * deeper subgroups (e.g. `organizations sites`, `spaces content pages`) are + * invisible until you drill in one level at a time. This module appends a + * "Command groups (nested)" section to every group's help that reveals the + * nested subgroup structure, so you can discover that `organizations sites` + * exists from `gitbook --help` without hunting. + * + * Two layouts, so the big root listing stays scannable without scrolling while + * deeper pages read naturally: + * - root `gitbook --help`: COMPACT — one line per top-level group, its + * subgroups listed comma-separated (and wrapped) alongside it. + * - any subgroup's `--help`: INDENTED — that group's subgroups one per line. + * + * It's attached uniformly by walking the Commander tree at runtime, so there's + * nothing to keep in sync as commands change. Only *subgroups* are listed — leaf + * verbs (list/get/create/…) are omitted to keep the tree structural; each + * group's own `--help` still lists its verbs. + */ + +// How many further levels of nested subgroups the INDENTED (subgroup-page) +// layout shows beneath the helped command's direct children. 1 reveals the +// immediate subgroups without drilling in while keeping output bounded — depth 2 +// explodes on wide subtrees like `sites` (~30 nested collections). +const TREE_DEPTH = 1; + +const INDENT = ' '; + +// A group's subgroups: visible children that themselves have visible children. +// Leaf verb commands (and the implicit `help` command) have no children and are +// excluded. Sorted alphabetically via the shared, sort-enabled Help instance. +function subgroups(cmd: Command, helper: Help): Command[] { + return helper.visibleCommands(cmd).filter((child) => helper.visibleCommands(child).length > 0); +} + +// ─── Indented layout (subgroup pages) ────────────────────────────────────────── + +function renderIndented( + cmd: Command, + helper: Help, + indent: number, + depthRemaining: number, + out: string[], +): void { + for (const child of subgroups(cmd, helper)) { + out.push(`${INDENT.repeat(indent)}${child.name()}`); + if (depthRemaining > 0) { + renderIndented(child, helper, indent + 1, depthRemaining - 1, out); + } + } +} + +function indentedSection(cmd: Command, helper: Help): string { + const out: string[] = []; + renderIndented(cmd, helper, 1, TREE_DEPTH, out); + return `\nCommand groups (nested):\n${out.join('\n')}\n`; +} + +// ─── Compact layout (root) ──────────────────────────────────────────────────-- + +// Greedily pack comma-separated tokens into lines no wider than `width`. +function wrapTokens(tokens: string[], width: number): string[] { + const lines: string[] = []; + let cur = ''; + tokens.forEach((tok, i) => { + const piece = i < tokens.length - 1 ? `${tok},` : tok; + if (cur === '') { + cur = piece; + } else if (cur.length + 1 + piece.length <= width) { + cur += ` ${piece}`; + } else { + lines.push(cur); + cur = piece; + } + }); + if (cur) lines.push(cur); + return lines; +} + +function compactSection(cmd: Command, helper: Help): string { + const groups = subgroups(cmd, helper); + const rows = groups.map((g) => ({ + name: g.name(), + children: subgroups(g, helper).map((c) => c.name()), + })); + + const nameWidth = Math.max(...rows.map((r) => r.name.length)); + const valueCol = INDENT.length + nameWidth + 2; + const termWidth = Math.min(process.stdout.columns || 80, 100); + // Leave room for the value column; never wrap narrower than 20 cols. + const wrapWidth = Math.max(20, termWidth - valueCol); + + const lines: string[] = []; + for (const row of rows) { + const label = INDENT + row.name.padEnd(nameWidth + 2); + if (row.children.length === 0) { + lines.push(label.trimEnd()); + continue; + } + const wrapped = wrapTokens(row.children, wrapWidth); + lines.push(label + wrapped[0]); + for (let i = 1; i < wrapped.length; i++) { + lines.push(' '.repeat(valueCol) + wrapped[i]); + } + } + + return `\nCommand groups (nested):\n${lines.join('\n')}\n`; +} + +// ─── Wiring ─────────────────────────────────────────────────────────────────-- + +/** + * Attach the nested-tree help section to `program` (compact) and every group + * beneath it (indented). + */ +export function installCommandTreeHelp(program: Command): void { + const helper = new Help(); + // Render subgroups alphabetically rather than in registration order. + helper.sortSubcommands = true; + + const attach = (cmd: Command, isRoot: boolean): void => { + // Only groups with nested subgroups get a tree; a group whose children + // are all leaf verbs has nothing extra to reveal. + if (subgroups(cmd, helper).length > 0) { + cmd.addHelpText('after', () => + isRoot ? compactSection(cmd, helper) : indentedSection(cmd, helper), + ); + } + for (const child of cmd.commands) { + attach(child, false); + } + }; + + attach(program, true); +} From 292ffc912ecc14ddf8a41a5abf41dfe9985745b5 Mon Sep 17 00:00:00 2001 From: Tal Gluck Date: Thu, 16 Jul 2026 14:35:05 +0200 Subject: [PATCH 03/11] CLI: accept path params as both positionals and --flags (RND-12046) Generated commands now register each OpenAPI path parameter as an optional positional AND a -- flag. The positional wins when both are supplied. Missing params produce a helpful error showing both invocation forms. --- scripts/generate-commands.ts | 40 ++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/scripts/generate-commands.ts b/scripts/generate-commands.ts index 1d495b923..56b7b5f80 100644 --- a/scripts/generate-commands.ts +++ b/scripts/generate-commands.ts @@ -865,13 +865,23 @@ function emitSimpleCommand( ): string { const { route, verb } = cmd; const lines: string[] = []; - const argStr = route.pathParams.map((p) => `<${p.name}>`).join(' '); + // Path params are documented as positionals (optional in the signature so the + // flag form is also accepted), and additionally offered as -- flags for + // discoverability. The positional wins when both are supplied (resolved below). + const argStr = route.pathParams.map((p) => `[${p.name}]`).join(' '); const fullCmd = argStr ? `${verb} ${argStr}` : verb; lines.push(`${I}${parentVar}`); lines.push(`${I} .command('${fullCmd}')`); lines.push(`${I} .description('${escapeStr(route.summary)}')`); + for (const p of route.pathParams) { + const desc = escapeStr( + `${p.description ? `${p.description} ` : ''}(path parameter — may be given positionally instead)`, + ); + lines.push(`${I} .option('--${p.name} ', '${desc}')`); + } + for (const qp of route.queryParams) { lines.push(emitQueryOption(qp, I)); } @@ -903,8 +913,34 @@ function emitSimpleCommand( lines.push(...emitOutputFlags(I)); const paramNames = route.pathParams.map((p) => camelize(p.name)); - const actionArgs = [...paramNames, 'options'].join(', '); + // Positionals arrive as `Arg`; the real `` const is the resolved + // value (positional first, then the --flag) that the request call reads. + const actionArgs = [...paramNames.map((n) => `${n}Arg`), 'options'].join(', '); lines.push(`${I} .action(async (${actionArgs}) => {`); + for (const p of route.pathParams) { + const n = camelize(p.name); + lines.push(`${I} const ${n} = ${n}Arg ?? options.${n};`); + } + if (route.pathParams.length > 0) { + const posUsage = escapeStr( + ['gitbook', ...cmd.group, verb, ...route.pathParams.map((p) => `<${p.name}>`)].join(' '), + ); + const flagUsage = escapeStr(route.pathParams.map((p) => `--${p.name} `).join(' ')); + lines.push(`${I} const missingParams: string[] = [];`); + for (const p of route.pathParams) { + lines.push( + `${I} if (${camelize(p.name)} === undefined) missingParams.push('${p.name}');`, + ); + } + lines.push(`${I} if (missingParams.length > 0) {`); + lines.push( + `${I} console.error(\`Missing required path parameter(s): \${missingParams.join(', ')}.\`);`, + ); + lines.push(`${I} console.error(' positional: ${posUsage}');`); + lines.push(`${I} console.error(' or flags: ${flagUsage}');`); + lines.push(`${I} process.exit(1);`); + lines.push(`${I} }`); + } lines.push(`${I} const api = await getAPIClient(true);`); if (route.queryParams.length > 0) { From c93a2dbe1752b1484e205604415b248e5ca617e1 Mon Sep 17 00:00:00 2001 From: Tal Gluck Date: Thu, 16 Jul 2026 15:11:01 +0200 Subject: [PATCH 04/11] CLI: add the 5 SSE streaming endpoints (RND-12044) Streaming operations are no longer skipped by the generator; they are tagged and emitted via a dedicated path that iterates the EventIterator through a new StreamRenderer in output.ts. Machine modes stream one record per event (NDJSON for --json, a YAML document stream for --yaml); pretty mode renders recommended questions live, answer text incrementally, agent-response events as status lines, and sources as end-of-stream citations. SIGINT flushes and exits 130; a mid-flight error surfaces after the partial output. Adds renderer unit tests. Commands (mechanical naming, each mirrors its URL and sits beside its buffered sibling): organizations ask stream, organizations ask questions stream, organizations sites ask stream, organizations sites ask questions stream, organizations sites ai response stream. --- packages/cli/src/output.test.ts | 97 ++++++++++++++++++++ packages/cli/src/output.ts | 153 ++++++++++++++++++++++++++++++++ scripts/generate-commands.ts | 84 +++++++++++++++--- 3 files changed, 322 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/output.test.ts b/packages/cli/src/output.test.ts index 2c2d72022..0954997e8 100644 --- a/packages/cli/src/output.test.ts +++ b/packages/cli/src/output.test.ts @@ -4,9 +4,11 @@ import { asCollection, coerceArrayQueryParam, coerceBodyFlag, + createStreamRenderer, explainApiError, formatCollection, formatObjectSummary, + formatStreamSource, isPlainObject, normalizeChangesMarkdown, normalizeMarkdown, @@ -15,6 +17,22 @@ import { summaryCell, } from './output'; +// Capture everything the renderer writes to stdout during `fn`. +function captureStdout(fn: () => void): string { + const original = process.stdout.write.bind(process.stdout); + let buffer = ''; + (process.stdout as unknown as { write: (chunk: unknown) => boolean }).write = (chunk) => { + buffer += String(chunk); + return true; + }; + try { + fn(); + } finally { + (process.stdout as unknown as { write: typeof original }).write = original; + } + return buffer; +} + describe('coerceBodyFlag', () => { it('parses an array flag string into a real array', () => { const result = coerceBodyFlag('[{"op":"add"}]', 'array'); @@ -289,3 +307,82 @@ describe('normalizeChangesMarkdown', () => { expect(() => normalizeChangesMarkdown(undefined)).not.toThrow(); }); }); + +describe('createStreamRenderer', () => { + it('--json emits one NDJSON record per event', () => { + const out = captureStdout(() => { + const r = createStreamRenderer({ json: true }); + r.write({ question: 'a' }); + r.write({ question: 'b' }); + r.end(); + }); + expect(out).toBe('{"question":"a"}\n{"question":"b"}\n'); + }); + + it('--yaml emits one YAML document per event, marker-delimited', () => { + const out = captureStdout(() => { + const r = createStreamRenderer({ yaml: true }); + r.write({ a: 1 }); + r.write({ b: 2 }); + r.end(); + }); + expect(out).toBe('---\na: 1\n---\nb: 2\n'); + }); + + it('pretty renders a recommended-question stream as bullets', () => { + const out = captureStdout(() => { + const r = createStreamRenderer({ pretty: true }); + r.write({ question: 'How do I start?' }); + r.write({ question: 'What is X?' }); + r.end(); + }); + expect(out).toBe('• How do I start?\n• What is X?\n'); + }); + + it('pretty prints answer text incrementally, then sources and follow-ups', () => { + const out = captureStdout(() => { + const r = createStreamRenderer({ pretty: true }); + // Each event carries the answer so far; only the new suffix prints. + r.write({ type: 'answer', answer: { answer: 'Hello', sources: [], followupQuestions: [] } }); + r.write({ + type: 'answer', + answer: { + answer: 'Hello world', + sources: [{ type: 'page', page: 'p1', space: 's1' }], + followupQuestions: ['Tell me more?'], + }, + }); + r.end(); + }); + expect(out).toBe( + 'Hello world\n\nSources:\n - page p1 space s1\n\nFollow-up questions:\n - Tell me more?\n', + ); + }); + + it('pretty renders agent-response events as concise status lines', () => { + const out = captureStdout(() => { + const r = createStreamRenderer({ pretty: true }); + r.write({ type: 'response_start', messageId: 'm1' }); + r.write({ type: 'response_document', operation: 'insert', blocks: [{}, {}] }); + r.write({ type: 'response_finish', messageId: 'm1', response: {} }); + r.end(); + }); + expect(out).toBe( + '[response_start]\n[response_document] insert — 2 block(s)\n[response_finish]\n', + ); + }); +}); + +describe('formatStreamSource', () => { + it('renders a page source with reason', () => { + expect(formatStreamSource({ type: 'page', page: 'p1', space: 's1', reason: 'covers it' })).toBe( + 'page p1 space s1 — covers it', + ); + }); + + it('renders a context-record source with title and url', () => { + expect( + formatStreamSource({ type: 'record', title: 'Guide', url: 'https://x/guide' }), + ).toBe('Guide https://x/guide'); + }); +}); diff --git a/packages/cli/src/output.ts b/packages/cli/src/output.ts index 24704a4f2..2be706548 100644 --- a/packages/cli/src/output.ts +++ b/packages/cli/src/output.ts @@ -321,3 +321,156 @@ export function printResult(data: unknown, options: OutputOptions): void { } } } + +// ─── Streaming output ────────────────────────────────────────────────────────── + +// The SSE endpoints (organization/site `ask`, recommended `questions`, and the +// site agent `ai-response`) yield a sequence of events rather than one response, +// so they can't go through printResult. A StreamRenderer consumes those events as +// they arrive: `write` per event, `end` once the stream closes (or errors). +export interface StreamRenderer { + write(event: unknown): void; + end(): void; +} + +// Machine formats stream one record per event so a consumer can parse the output +// incrementally: NDJSON for --json (one compact object per line), a stream of +// YAML documents for --yaml (each introduced by the `---` marker). Pretty mode +// renders progressively (see createPrettyStreamRenderer). Format resolution — and +// the piped-defaults-to-YAML behaviour — is shared with printResult. +export function createStreamRenderer(options: OutputOptions): StreamRenderer { + const format = resolveFormat(options); + if (format === 'json') { + return { + write: (event) => process.stdout.write(`${JSON.stringify(event)}\n`), + end: () => {}, + }; + } + if (format === 'yaml') { + return { + write: (event) => + process.stdout.write(`---\n${jsyaml.dump(event, { indent: 2, lineWidth: 120 })}`), + end: () => {}, + }; + } + return createPrettyStreamRenderer(); +} + +// Human-readable streaming. The three event shapes these endpoints emit are each +// rendered on the fly: +// - recommended-question events (`{ question }`) → one bullet per question +// - answer snapshots (`{ type: 'answer', answer }`) → the answer text, printed +// incrementally as the snapshot grows, then its sources as citations at end +// - agent-response events (`{ type: 'response_*' }`) → a concise status line +// Unknown shapes fall back to a compact block so nothing is silently dropped. +function createPrettyStreamRenderer(): StreamRenderer { + let printedAnswerLen = 0; // chars of the (string) answer already written + let midLine = false; // last write left the cursor mid-line + let sources: unknown[] | null = null; + let followups: unknown[] | null = null; + + const out = (s: string) => { + if (s.length === 0) return; + process.stdout.write(s); + midLine = !s.endsWith('\n'); + }; + const line = (s: string) => { + process.stdout.write(`${s}\n`); + midLine = false; + }; + + return { + write(event: unknown): void { + if (typeof event === 'string') { + out(event); + return; + } + if (!isPlainObject(event)) { + line(formatScalar(event)); + return; + } + // Recommended-question stream: one `{ question }` per event. + if (typeof event.question === 'string' && event.type === undefined) { + line(`• ${event.question}`); + return; + } + // Answer stream: progressive `{ type: 'answer', answer }` snapshots. + // Each event carries the answer so far, so we print only the new + // suffix; sources/followups from the latest snapshot render at end. + if (event.type === 'answer' && isPlainObject(event.answer)) { + const answer = event.answer; + if (Array.isArray(answer.sources)) sources = answer.sources; + if (Array.isArray(answer.followupQuestions)) followups = answer.followupQuestions; + const text = streamAnswerText(answer.answer); + if (text !== undefined) { + if (text.length >= printedAnswerLen) out(text.slice(printedAnswerLen)); + else out(`\n${text}`); // shrank unexpectedly: reprint whole + printedAnswerLen = text.length; + } + return; + } + // Agent-response stream (AIStreamResponse): a status line per event, + // except the JSON-object chunks which are raw text to concatenate. + if (typeof event.type === 'string') { + if (typeof event.jsonChunk === 'string') { + out(event.jsonChunk); + return; + } + line(streamStatusLine(event)); + return; + } + line(formatPretty(event)); + }, + end(): void { + if (midLine) { + process.stdout.write('\n'); + midLine = false; + } + if (sources && sources.length > 0) { + line(''); + line('Sources:'); + for (const s of sources) line(` - ${formatStreamSource(s)}`); + } + if (followups && followups.length > 0) { + line(''); + line('Follow-up questions:'); + for (const q of followups) line(` - ${formatScalar(q)}`); + } + }, + }; +} + +// Renderable text for an answer field. `--format markdown` yields a string; the +// default `document` form has no direct text rendering here, so it is skipped +// (its sources still render as citations once the stream ends). +function streamAnswerText(answer: unknown): string | undefined { + if (typeof answer === 'string') return answer; + if (isPlainObject(answer) && typeof answer.markdown === 'string') return answer.markdown; + return undefined; +} + +// One-line status for an AIStreamResponse event, keyed by its `type`. +function streamStatusLine(event: Record): string { + const type = String(event.type); + if (type === 'response_document' || type === 'response_reasoning') { + const n = Array.isArray(event.blocks) ? event.blocks.length : 0; + return `[${type}] ${formatScalar(event.operation)} — ${n} block(s)`; + } + return `[${type}]`; +} + +// A citation line for a SearchAIAnswerSource (a page or a context record). +export function formatStreamSource(source: unknown): string { + if (!isPlainObject(source)) return formatScalar(source); + const parts: string[] = []; + if (source.type === 'record') { + if (typeof source.title === 'string') parts.push(source.title); + if (typeof source.url === 'string') parts.push(source.url); + else if (typeof source.record === 'string') parts.push(`record ${source.record}`); + } else { + if (typeof source.page === 'string') parts.push(`page ${source.page}`); + if (typeof source.space === 'string') parts.push(`space ${source.space}`); + } + if (typeof source.reason === 'string' && source.reason) parts.push(`— ${source.reason}`); + return parts.length ? parts.join(' ') : formatPretty(source); +} diff --git a/scripts/generate-commands.ts b/scripts/generate-commands.ts index 56b7b5f80..5820311ce 100644 --- a/scripts/generate-commands.ts +++ b/scripts/generate-commands.ts @@ -267,9 +267,9 @@ function isPublicOperation(operation: Operation, globalSecurity: SecurityRequire // SSE endpoints (text/event-stream). Their generated client methods return an // EventIterator rather than a Promise, so they don't fit the -// one-response-per-command model this CLI emits — consuming a stream needs -// dedicated handling. They're skipped for now (they were never usable here -// anyway: the previous JSON.parse(response.text()) path chokes on SSE framing). +// one-response-per-command model. They get a dedicated emit path +// (emitStreamingCommand) that iterates the events through a StreamRenderer +// instead of the buffered printResult path; this flag routes them there. function isStreamingOperation(operation: Operation): boolean { for (const response of Object.values(operation.responses ?? {})) { if (response.content && 'text/event-stream' in response.content) return true; @@ -545,6 +545,7 @@ interface Route { queryParams: ParameterObject[]; bodyFlags: BodyFlag[] | null; hasBody: boolean; + isStreaming: boolean; // SSE endpoint → emitted via the streaming path naming: Naming; } @@ -584,7 +585,6 @@ function buildRoutes(spec: OpenAPISpec): Route[] { const operation = pathItem[method]; if (!operation?.operationId) continue; if (!isPublicOperation(operation, globalSecurity)) continue; - if (isStreamingOperation(operation)) continue; const upper = method.toUpperCase(); if (EXCLUDE.has(`${upper} ${apiPath}`)) continue; @@ -616,6 +616,7 @@ function buildRoutes(spec: OpenAPISpec): Route[] { queryParams, bodyFlags, hasBody, + isStreaming: isStreamingOperation(operation), naming: computeNaming( upper, apiPath, @@ -858,11 +859,17 @@ function emitQueryAssign(qp: ParameterObject, I: string): string { return `${I} if (${accessor} !== undefined) query['${qp.name}'] = ${rhs};`; } -function emitSimpleCommand( +// Everything a simple/streaming command has in common: the command declaration, +// path-param positionals + --flags, query/body options, output flags, the action +// signature, path-param resolution, and query/body assembly. The action block is +// left OPEN — the caller appends the request (emitSimpleCommand) or the streaming +// loop (emitStreamingCommand) and closes it — and `callArgs` is the ordered arg +// list for the generated client method (path params, then body, then query). +function emitCommandPreamble( cmd: Extract, parentVar: string, I: string, -): string { +): { lines: string[]; callArgs: string[] } { const { route, verb } = cmd; const lines: string[] = []; // Path params are documented as positionals (optional in the signature so the @@ -989,6 +996,16 @@ function emitSimpleCommand( if (route.hasBody) callArgs.push('body as never'); if (route.queryParams.length > 0) callArgs.push('query as never'); + return { lines, callArgs }; +} + +function emitSimpleCommand( + cmd: Extract, + parentVar: string, + I: string, +): string { + const { route } = cmd; + const { lines, callArgs } = emitCommandPreamble(cmd, parentVar, I); lines.push( ...emitRequest(I, { namespace: clientNamespace(route.apiPath), @@ -1001,6 +1018,47 @@ function emitSimpleCommand( return lines.join('\n'); } +// SSE endpoint: iterate the event stream through a StreamRenderer instead of the +// buffered printResult path. A partial answer stays on screen if the stream errors +// mid-flight (renderer.end() flushes first, then the error prints); SIGINT flushes +// what streamed so far and exits 130. The client method returns an EventIterator, +// cast to AsyncIterable for `for await`. +function emitStreamingCommand( + cmd: Extract, + parentVar: string, + I: string, +): string { + const { route } = cmd; + const { lines, callArgs } = emitCommandPreamble(cmd, parentVar, I); + const ns = clientNamespace(route.apiPath); + const method = toClientMethod(route.operationId); + const target = ns ? `api.${ns}.${method}` : `api.${method}`; + lines.push(`${I} const stream = createStreamRenderer(options);`); + lines.push(`${I} const onSigint = () => {`); + lines.push(`${I} stream.end();`); + lines.push(`${I} process.exit(130);`); + lines.push(`${I} };`); + lines.push(`${I} process.on('SIGINT', onSigint);`); + lines.push(`${I} try {`); + lines.push( + `${I} const events = ${target}(${callArgs.join(', ')}) as AsyncIterable;`, + ); + lines.push(`${I} for await (const event of events) {`); + lines.push(`${I} stream.write(event);`); + lines.push(`${I} }`); + lines.push(`${I} stream.end();`); + lines.push(`${I} } catch (error) {`); + lines.push(`${I} stream.end();`); + lines.push(`${I} console.error(explainApiError((error as Error).message));`); + lines.push(`${I} process.exitCode = 1;`); + lines.push(`${I} } finally {`); + lines.push(`${I} process.off('SIGINT', onSigint);`); + lines.push(`${I} }`); + lines.push(`${I} });`); + lines.push(''); + return lines.join('\n'); +} + function emitMergedCommand( cmd: Extract, parentVar: string, @@ -1094,11 +1152,13 @@ function emitNode(node: GroupNode, pathParts: string[], lines: string[]): void { const parentVar = pathParts.length === 0 ? 'program' : varNameForPath(pathParts); for (const cmd of node.commands) { - lines.push( - cmd.kind === 'simple' - ? emitSimpleCommand(cmd, parentVar, ' ') - : emitMergedCommand(cmd, parentVar, ' '), - ); + if (cmd.kind === 'merged') { + lines.push(emitMergedCommand(cmd, parentVar, ' ')); + } else if (cmd.route.isStreaming) { + lines.push(emitStreamingCommand(cmd, parentVar, ' ')); + } else { + lines.push(emitSimpleCommand(cmd, parentVar, ' ')); + } } for (const [name, child] of node.children) { @@ -1130,7 +1190,7 @@ function emitFile(tree: GroupNode, completions: Record): string lines.push(`// Output formatting lives in ./output (a real, unit-tested module) rather than`); lines.push(`// being inlined here, so the rendering logic has a single source of truth.`); lines.push( - `import { printResult, coerceBodyFlag, coerceArrayQueryParam, explainApiError, normalizeChangesMarkdown } from './output';`, + `import { printResult, coerceBodyFlag, coerceArrayQueryParam, explainApiError, normalizeChangesMarkdown, createStreamRenderer } from './output';`, ); lines.push(``); lines.push(`export function registerGeneratedCommands(program: Command): void {`); From d4b4d75ad840922c7388963c5d1f5bd241f42c3d Mon Sep 17 00:00:00 2001 From: Tal Gluck Date: Thu, 16 Jul 2026 15:13:37 +0200 Subject: [PATCH 05/11] CLI: fall back to spec-derived OAuth scopes when discovery omits them (RND-12043) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gitbook login already requests scopes from the OAuth server's .well-known discovery metadata (environment-correct), not a hardcoded list. This adds the spec's oauth securityScheme scopes as a generated fallback (generate-commands.ts now also emits generated-oauth-scopes.ts), used only when discovery's scopes_supported is absent — so login never silently requests zero scopes. The OAuth endpoint stays runtime-derived (the bundled spec is prod-only). --- packages/cli/.gitignore | 1 + packages/cli/src/oauth.ts | 9 ++++-- scripts/generate-commands.ts | 54 ++++++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/packages/cli/.gitignore b/packages/cli/.gitignore index ccef655db..34348eb70 100644 --- a/packages/cli/.gitignore +++ b/packages/cli/.gitignore @@ -3,3 +3,4 @@ dist/ # Auto-generated from packages/api/spec/openapi.json by scripts/generate-commands.ts # (regenerated by the `prebuild` step); not hand-edited, so not tracked. src/generated-commands.ts +src/generated-oauth-scopes.ts diff --git a/packages/cli/src/oauth.ts b/packages/cli/src/oauth.ts index e5e2db936..a8d5a8373 100644 --- a/packages/cli/src/oauth.ts +++ b/packages/cli/src/oauth.ts @@ -7,6 +7,7 @@ import getPort from 'get-port'; import { version } from '../package.json'; import type { OAuthSession } from './config'; +import { OAUTH_SCOPES } from './generated-oauth-scopes'; const CLIENT_NAME = 'GitBook CLI'; const CLIENT_URI = 'https://github.com/GitbookIO/integrations'; @@ -173,8 +174,12 @@ export async function authenticateWithBrowser({ ); const state = base64URLEncode(crypto.randomBytes(16)); - // Request every scope the server supports; the user narrows it on the consent screen. - const scope = (metadata.scopes_supported ?? []).join(' '); + // Request every scope the server supports; the user narrows it on the consent + // screen. Prefer the server's discovery metadata (environment-correct); fall + // back to the spec-derived scope list only when discovery omits it, so login + // never silently requests zero scopes. See generated-oauth-scopes.ts. + const supported = metadata.scopes_supported ?? []; + const scope = (supported.length > 0 ? supported : OAUTH_SCOPES).join(' '); const authorizationURL = new URL(metadata.authorization_endpoint); authorizationURL.searchParams.set('response_type', 'code'); diff --git a/scripts/generate-commands.ts b/scripts/generate-commands.ts index 5820311ce..5d86b7fa1 100644 --- a/scripts/generate-commands.ts +++ b/scripts/generate-commands.ts @@ -63,11 +63,22 @@ interface OpenAPISpec { components?: { schemas?: Record; parameters?: Record; + securitySchemes?: Record; }; tags?: TagObject[]; security?: SecurityRequirement[]; } +interface SecuritySchemeObject { + type?: string; + flows?: { + authorizationCode?: { + // scope name → human description + scopes?: Record; + }; + }; +} + interface PathItem { get?: Operation; post?: Operation; @@ -1296,10 +1307,47 @@ ${bash}`; return { bash, zsh, fish: fishLines.join('\n') + '\n' }; } +// ─── OAuth scopes ───────────────────────────────────────────────────────────-- + +// The authoritative OAuth scope list lives in the spec's `oauth` securityScheme. +// The CLI's `gitbook login` normally requests the scopes the OAuth server +// advertises via `.well-known` discovery (which is environment-correct — the +// bundled spec is prod-only); this generated list is the fallback for a server +// whose discovery document omits `scopes_supported`, so login never silently +// requests zero scopes. Scopes are environment-independent, so the prod spec is a +// safe source for them (unlike the OAuth *endpoint*, which stays runtime-derived). +function extractOAuthScopes(spec: OpenAPISpec): string[] { + const scopes = + spec.components?.securitySchemes?.oauth?.flows?.authorizationCode?.scopes ?? {}; + return Object.keys(scopes); +} + +function emitOAuthScopesFile(scopes: string[]): string { + return [ + `/**`, + ` * AUTO-GENERATED — DO NOT EDIT`, + ` *`, + ` * Source: packages/api/spec/openapi.json (components.securitySchemes.oauth)`, + ` * Generator: scripts/generate-commands.ts`, + ` *`, + ` * Re-generate: npm run generate-commands (from monorepo root)`, + ` */`, + ``, + `// Fallback OAuth scopes, used by \`gitbook login\` only when the OAuth server's`, + `// discovery metadata omits \`scopes_supported\`. See oauth.ts.`, + `export const OAUTH_SCOPES: string[] = ${JSON.stringify(scopes, null, 4)};`, + ``, + ].join('\n'); +} + // ─── Entry point ────────────────────────────────────────────────────────────── const SPEC_PATH = path.resolve(__dirname, '../packages/api/spec/openapi.json'); const OUT_PATH = path.resolve(__dirname, '../packages/cli/src/generated-commands.ts'); +const OAUTH_SCOPES_OUT_PATH = path.resolve( + __dirname, + '../packages/cli/src/generated-oauth-scopes.ts', +); console.log('Reading spec from', SPEC_PATH); const spec = loadSpec(SPEC_PATH); @@ -1312,9 +1360,15 @@ const completions = generateCompletions(commands); const merged = commands.filter((c) => c.kind === 'merged').length; const complexBody = routes.filter((r) => r.hasBody && r.bodyFlags === null).length; +const oauthScopes = extractOAuthScopes(spec); + console.log(` ${routes.length} public operations included`); console.log(` ${commands.length} commands (${merged} merged scope-flag commands)`); console.log(` ${complexBody} operations using --body fallback (complex schema)`); +console.log(` ${oauthScopes.length} OAuth scopes (login fallback)`); fs.writeFileSync(OUT_PATH, emitFile(tree, completions), 'utf8'); console.log('Written to', OUT_PATH); + +fs.writeFileSync(OAUTH_SCOPES_OUT_PATH, emitOAuthScopesFile(oauthScopes), 'utf8'); +console.log('Written to', OAUTH_SCOPES_OUT_PATH); From dd28f80f6b629f953501e33af4fa68240e0cda23 Mon Sep 17 00:00:00 2001 From: Tal Gluck Date: Thu, 16 Jul 2026 15:16:11 +0200 Subject: [PATCH 06/11] Add changeset for CLI generator internals (RND-12046, RND-12044, RND-12043) --- .changeset/cli-generator-internals.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/cli-generator-internals.md diff --git a/.changeset/cli-generator-internals.md b/.changeset/cli-generator-internals.md new file mode 100644 index 000000000..94291d587 --- /dev/null +++ b/.changeset/cli-generator-internals.md @@ -0,0 +1,5 @@ +--- +'@gitbook/cli': patch +--- + +Generated commands now accept each path parameter as both a positional argument and a `--` flag (the positional wins when both are given), with a clearer error when a required path parameter is missing. Added commands for the 5 streaming (SSE) API endpoints — `organizations ask stream`, `organizations ask questions stream`, `organizations sites ask stream`, `organizations sites ask questions stream`, and `organizations sites ai response stream` — which stream NDJSON/YAML records in machine mode and render incrementally in pretty mode. `gitbook login` now falls back to the spec-derived OAuth scope list when the server's discovery metadata omits `scopes_supported`. From 0f77fa92dff9ad57f81d3e8611ac0536c38d8fda Mon Sep 17 00:00:00 2001 From: Tal Gluck Date: Thu, 16 Jul 2026 16:26:47 +0200 Subject: [PATCH 07/11] CLI: add an idle request timeout to generated commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated commands (buffered, merged, and streaming) now abort a request that produces no response — for streams, no next event — within CLI_TIMEOUT_MS (default 60s, override via GITBOOK_CLI_TIMEOUT_MS, 0 disables), so the CLI fails loudly with explainTimeout() instead of hanging on an unresponsive endpoint. The stream timer resets on each event so a slow-but-live response is not cut off. Adds createRequestTimeout/explainTimeout to output.ts with unit tests. --- packages/cli/src/output.test.ts | 19 ++++++++++++++ packages/cli/src/output.ts | 44 +++++++++++++++++++++++++++++++++ scripts/generate-commands.ts | 39 ++++++++++++++++++++++++++--- 3 files changed, 98 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/output.test.ts b/packages/cli/src/output.test.ts index 0954997e8..a0e262359 100644 --- a/packages/cli/src/output.test.ts +++ b/packages/cli/src/output.test.ts @@ -2,10 +2,13 @@ import { describe, it, expect } from 'bun:test'; import { asCollection, + CLI_TIMEOUT_MS, coerceArrayQueryParam, coerceBodyFlag, + createRequestTimeout, createStreamRenderer, explainApiError, + explainTimeout, formatCollection, formatObjectSummary, formatStreamSource, @@ -373,6 +376,22 @@ describe('createStreamRenderer', () => { }); }); +describe('createRequestTimeout', () => { + it('returns a live, un-aborted timeout by default (and clear() stops the timer)', () => { + const t = createRequestTimeout(); + // The default CLI_TIMEOUT_MS is non-zero, so a timeout is created. + expect(t).not.toBeNull(); + expect(t!.signal.aborted).toBe(false); + t!.clear(); // release the pending timer so the test process can exit + }); + + it('explainTimeout names the configured duration and the override env var', () => { + const msg = explainTimeout(); + expect(msg).toContain(`${CLI_TIMEOUT_MS / 1000}s`); + expect(msg).toContain('GITBOOK_CLI_TIMEOUT_MS'); + }); +}); + describe('formatStreamSource', () => { it('renders a page source with reason', () => { expect(formatStreamSource({ type: 'page', page: 'p1', space: 's1', reason: 'covers it' })).toBe( diff --git a/packages/cli/src/output.ts b/packages/cli/src/output.ts index 2be706548..9da8a6fab 100644 --- a/packages/cli/src/output.ts +++ b/packages/cli/src/output.ts @@ -108,6 +108,50 @@ export function explainApiError(message: string): string { return message; } +// ─── Request timeout ──────────────────────────────────────────────────────────── + +// Generated commands abort a request that produces no response — for streams, no +// next event — within this many ms, so the CLI fails loudly instead of hanging on +// an unresponsive endpoint. Configurable via GITBOOK_CLI_TIMEOUT_MS (0 disables). +export const CLI_TIMEOUT_MS: number = ((): number => { + const raw = process.env.GITBOOK_CLI_TIMEOUT_MS; + if (raw === undefined) return 60_000; + const n = Number(raw); + return Number.isFinite(n) && n >= 0 ? n : 60_000; +})(); + +export interface RequestTimeout { + signal: AbortSignal; + // Reset the idle timer — called per streamed event so a long but live + // response is never cut off; a buffered request just lets it run once. + bump: () => void; + clear: () => void; +} + +// An abort signal that fires after CLI_TIMEOUT_MS of inactivity, or null when +// timeouts are disabled (CLI_TIMEOUT_MS === 0). The generated command passes +// `signal` to the client, calls `bump()` on each event, and `clear()` when done. +export function createRequestTimeout(): RequestTimeout | null { + if (!CLI_TIMEOUT_MS) return null; + const controller = new AbortController(); + let timer: ReturnType; + const bump = () => { + clearTimeout(timer); + timer = setTimeout(() => controller.abort(), CLI_TIMEOUT_MS); + }; + const clear = () => clearTimeout(timer); + bump(); + return { signal: controller.signal, bump, clear }; +} + +export function explainTimeout(): string { + return ( + `Timed out after ${CLI_TIMEOUT_MS / 1000}s with no response from the server. ` + + `The endpoint may be slow or unavailable for this content. ` + + `Set GITBOOK_CLI_TIMEOUT_MS (milliseconds; 0 disables) to change.` + ); +} + // ─── Markdown round-trip normalization ────────────────────────────────────────── // Make page-document markdown safe to push back via update_page/insert_page. diff --git a/scripts/generate-commands.ts b/scripts/generate-commands.ts index 5d86b7fa1..b6732ed6a 100644 --- a/scripts/generate-commands.ts +++ b/scripts/generate-commands.ts @@ -780,17 +780,26 @@ function emitRequest( ): string[] { const target = opts.namespace ? `api.${opts.namespace}.${opts.method}` : `api.${opts.method}`; const lines: string[] = []; + lines.push(`${I} const timeout = createRequestTimeout();`); lines.push(`${I} try {`); lines.push(`${I} const response = await ${target}(${opts.args.join(', ')});`); + lines.push(`${I} timeout?.clear();`); lines.push(`${I} if (response.data != null) {`); lines.push(`${I} printResult(response.data, options);`); lines.push(`${I} }`); lines.push(`${I} } catch (error) {`); + lines.push(`${I} timeout?.clear();`); // A successful response with an empty body (e.g. 204/205 from a delete) makes // the client's `format: 'json'` parse reject with a SyntaxError — that's not // an API error, so there's simply nothing to print. lines.push(`${I} if (error instanceof SyntaxError) return;`); - lines.push(`${I} console.error(explainApiError((error as Error).message));`); + // The idle timer aborted the request: report the timeout, not the raw + // AbortError the client surfaces. + lines.push(`${I} if (timeout?.signal.aborted) {`); + lines.push(`${I} console.error(explainTimeout());`); + lines.push(`${I} } else {`); + lines.push(`${I} console.error(explainApiError((error as Error).message));`); + lines.push(`${I} }`); lines.push(`${I} process.exit(1);`); lines.push(`${I} }`); return lines; @@ -1006,6 +1015,9 @@ function emitCommandPreamble( const callArgs = [...paramNames]; if (route.hasBody) callArgs.push('body as never'); if (route.queryParams.length > 0) callArgs.push('query as never'); + // Final client arg is the request params object; we use it to pass the idle + // abort signal. `timeout` is declared by the caller before the request call. + callArgs.push('{ signal: timeout?.signal }'); return { lines, callArgs }; } @@ -1045,7 +1057,9 @@ function emitStreamingCommand( const method = toClientMethod(route.operationId); const target = ns ? `api.${ns}.${method}` : `api.${method}`; lines.push(`${I} const stream = createStreamRenderer(options);`); + lines.push(`${I} const timeout = createRequestTimeout();`); lines.push(`${I} const onSigint = () => {`); + lines.push(`${I} timeout?.clear();`); lines.push(`${I} stream.end();`); lines.push(`${I} process.exit(130);`); lines.push(`${I} };`); @@ -1055,12 +1069,20 @@ function emitStreamingCommand( `${I} const events = ${target}(${callArgs.join(', ')}) as AsyncIterable;`, ); lines.push(`${I} for await (const event of events) {`); + // Each event resets the idle timer, so a slow-but-live stream is never cut off. + lines.push(`${I} timeout?.bump();`); lines.push(`${I} stream.write(event);`); lines.push(`${I} }`); + lines.push(`${I} timeout?.clear();`); lines.push(`${I} stream.end();`); lines.push(`${I} } catch (error) {`); + lines.push(`${I} timeout?.clear();`); lines.push(`${I} stream.end();`); - lines.push(`${I} console.error(explainApiError((error as Error).message));`); + lines.push(`${I} if (timeout?.signal.aborted) {`); + lines.push(`${I} console.error(explainTimeout());`); + lines.push(`${I} } else {`); + lines.push(`${I} console.error(explainApiError((error as Error).message));`); + lines.push(`${I} }`); lines.push(`${I} process.exitCode = 1;`); lines.push(`${I} } finally {`); lines.push(`${I} process.off('SIGINT', onSigint);`); @@ -1120,6 +1142,7 @@ function emitMergedCommand( // Each scope selects a different API operation, so the typed call lives inside // the branch. Path args are the scope ids (options.); the query object, // when present, follows and is cast past the method's strict param type. + lines.push(`${I} const timeout = createRequestTimeout();`); lines.push(`${I} try {`); lines.push(`${I} let data: unknown;`); let first = true; @@ -1127,6 +1150,7 @@ function emitMergedCommand( lines.push(`${I} ${first ? 'if' : 'else if'} (provided === '${v.scopeKey}') {`); const callArgs = v.scope.map((s) => `options.${s.flag}`); if (hasQuery) callArgs.push('query as never'); + callArgs.push('{ signal: timeout?.signal }'); const ns = clientNamespace(v.apiPath); const method = toClientMethod(v.operationId); const target = ns ? `api.${ns}.${method}` : `api.${method}`; @@ -1137,17 +1161,24 @@ function emitMergedCommand( const flagList = scopeFlags.map((s) => `--${s.flag}`).join(', '); const noScopeNote = cmd.allowNoScope ? ' (or none for all)' : ''; lines.push(`${I} else {`); + lines.push(`${I} timeout?.clear();`); lines.push( `${I} console.error('Specify a valid scope${noScopeNote}: ${escapeStr(flagList)}. Some scopes require a combination (e.g. --integration with --installation).');`, ); lines.push(`${I} process.exit(1);`); lines.push(`${I} }`); + lines.push(`${I} timeout?.clear();`); lines.push(`${I} if (data != null) {`); lines.push(`${I} printResult(data, options);`); lines.push(`${I} }`); lines.push(`${I} } catch (error) {`); + lines.push(`${I} timeout?.clear();`); lines.push(`${I} if (error instanceof SyntaxError) return;`); - lines.push(`${I} console.error(explainApiError((error as Error).message));`); + lines.push(`${I} if (timeout?.signal.aborted) {`); + lines.push(`${I} console.error(explainTimeout());`); + lines.push(`${I} } else {`); + lines.push(`${I} console.error(explainApiError((error as Error).message));`); + lines.push(`${I} }`); lines.push(`${I} process.exit(1);`); lines.push(`${I} }`); lines.push(`${I} });`); @@ -1201,7 +1232,7 @@ function emitFile(tree: GroupNode, completions: Record): string lines.push(`// Output formatting lives in ./output (a real, unit-tested module) rather than`); lines.push(`// being inlined here, so the rendering logic has a single source of truth.`); lines.push( - `import { printResult, coerceBodyFlag, coerceArrayQueryParam, explainApiError, normalizeChangesMarkdown, createStreamRenderer } from './output';`, + `import { printResult, coerceBodyFlag, coerceArrayQueryParam, explainApiError, normalizeChangesMarkdown, createStreamRenderer, createRequestTimeout, explainTimeout } from './output';`, ); lines.push(``); lines.push(`export function registerGeneratedCommands(program: Command): void {`); From 90a06f0b9f14cedb42006bd9a21b618655ab22e8 Mon Sep 17 00:00:00 2001 From: Tal Gluck Date: Thu, 16 Jul 2026 19:44:56 +0200 Subject: [PATCH 08/11] Fix prettier formatting in generator and tests --- packages/cli/src/output.test.ts | 17 ++++++++++------- scripts/generate-commands.ts | 7 ++++--- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/output.test.ts b/packages/cli/src/output.test.ts index a0e262359..d6cbca778 100644 --- a/packages/cli/src/output.test.ts +++ b/packages/cli/src/output.test.ts @@ -346,7 +346,10 @@ describe('createStreamRenderer', () => { const out = captureStdout(() => { const r = createStreamRenderer({ pretty: true }); // Each event carries the answer so far; only the new suffix prints. - r.write({ type: 'answer', answer: { answer: 'Hello', sources: [], followupQuestions: [] } }); + r.write({ + type: 'answer', + answer: { answer: 'Hello', sources: [], followupQuestions: [] }, + }); r.write({ type: 'answer', answer: { @@ -394,14 +397,14 @@ describe('createRequestTimeout', () => { describe('formatStreamSource', () => { it('renders a page source with reason', () => { - expect(formatStreamSource({ type: 'page', page: 'p1', space: 's1', reason: 'covers it' })).toBe( - 'page p1 space s1 — covers it', - ); + expect( + formatStreamSource({ type: 'page', page: 'p1', space: 's1', reason: 'covers it' }), + ).toBe('page p1 space s1 — covers it'); }); it('renders a context-record source with title and url', () => { - expect( - formatStreamSource({ type: 'record', title: 'Guide', url: 'https://x/guide' }), - ).toBe('Guide https://x/guide'); + expect(formatStreamSource({ type: 'record', title: 'Guide', url: 'https://x/guide' })).toBe( + 'Guide https://x/guide', + ); }); }); diff --git a/scripts/generate-commands.ts b/scripts/generate-commands.ts index b6732ed6a..2ea17b9c0 100644 --- a/scripts/generate-commands.ts +++ b/scripts/generate-commands.ts @@ -950,7 +950,9 @@ function emitCommandPreamble( } if (route.pathParams.length > 0) { const posUsage = escapeStr( - ['gitbook', ...cmd.group, verb, ...route.pathParams.map((p) => `<${p.name}>`)].join(' '), + ['gitbook', ...cmd.group, verb, ...route.pathParams.map((p) => `<${p.name}>`)].join( + ' ', + ), ); const flagUsage = escapeStr(route.pathParams.map((p) => `--${p.name} `).join(' ')); lines.push(`${I} const missingParams: string[] = [];`); @@ -1348,8 +1350,7 @@ ${bash}`; // requests zero scopes. Scopes are environment-independent, so the prod spec is a // safe source for them (unlike the OAuth *endpoint*, which stays runtime-derived). function extractOAuthScopes(spec: OpenAPISpec): string[] { - const scopes = - spec.components?.securitySchemes?.oauth?.flows?.authorizationCode?.scopes ?? {}; + const scopes = spec.components?.securitySchemes?.oauth?.flows?.authorizationCode?.scopes ?? {}; return Object.keys(scopes); } From 625488516f0fa51f9fb8df9a3a99998f6f15589c Mon Sep 17 00:00:00 2001 From: Tal Gluck Date: Thu, 16 Jul 2026 20:23:34 +0200 Subject: [PATCH 09/11] CLI: default ask-stream format to markdown so pretty mode shows the answer The ask endpoints default `format` to `document` (a structured tree the pretty renderer can't turn into text), so a plain `ask stream` printed only the sources with no answer body. Default `format` to markdown when the user hasn't set it; `--format document` still gets the raw form. Verified end-to-end against a live site: the answer now streams in pretty mode without any extra flag. --- scripts/generate-commands.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/scripts/generate-commands.ts b/scripts/generate-commands.ts index 2ea17b9c0..dc7015b46 100644 --- a/scripts/generate-commands.ts +++ b/scripts/generate-commands.ts @@ -1058,6 +1058,17 @@ function emitStreamingCommand( const ns = clientNamespace(route.apiPath); const method = toClientMethod(route.operationId); const target = ns ? `api.${ns}.${method}` : `api.${method}`; + // The ask endpoints default `format` to `document` (a structured tree the CLI + // can't render as text), so a plain `ask stream` would print no answer — only + // sources. Default to `markdown` when the user hasn't chosen a format; they can + // still pass `--format document` for the raw form. `query` is declared by the + // preamble whenever the route has query params (all `format`-bearing ones do). + const formatQp = route.queryParams.find( + (qp) => qp.name === 'format' && (qp.schema?.enum ?? []).map(String).includes('markdown'), + ); + if (formatQp) { + lines.push(`${I} if (options.format === undefined) query['format'] = 'markdown';`); + } lines.push(`${I} const stream = createStreamRenderer(options);`); lines.push(`${I} const timeout = createRequestTimeout();`); lines.push(`${I} const onSigint = () => {`); From ffd20a4ed910b659af3be72d2d95318902e8040b Mon Sep 17 00:00:00 2001 From: Tal Gluck Date: Thu, 16 Jul 2026 20:53:00 +0200 Subject: [PATCH 10/11] CLI: render document-form ask answers in pretty mode Adds documentToText to flatten a GitBook document node tree to plain text, so `ask stream --format document` (and any answer that arrives as a document rather than a markdown string) still shows the answer body in pretty mode instead of only the sources. Marks are dropped; it's a legible fallback, not a full markdown serializer. Verified live + unit-tested. --- packages/cli/src/output.test.ts | 71 +++++++++++++++++++++++++++++++++ packages/cli/src/output.ts | 33 +++++++++++++-- 2 files changed, 100 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/output.test.ts b/packages/cli/src/output.test.ts index d6cbca778..6c18c7916 100644 --- a/packages/cli/src/output.test.ts +++ b/packages/cli/src/output.test.ts @@ -7,6 +7,7 @@ import { coerceBodyFlag, createRequestTimeout, createStreamRenderer, + documentToText, explainApiError, explainTimeout, formatCollection, @@ -365,6 +366,41 @@ describe('createStreamRenderer', () => { ); }); + it('pretty renders a document-form answer (--format document) as text', () => { + const out = captureStdout(() => { + const r = createStreamRenderer({ pretty: true }); + r.write({ + type: 'answer', + answer: { + answer: { + document: { + object: 'document', + nodes: [ + { + object: 'block', + type: 'paragraph', + nodes: [ + { + object: 'text', + leaves: [ + { object: 'leaf', text: 'Hello ' }, + { object: 'leaf', text: 'world' }, + ], + }, + ], + }, + ], + }, + }, + sources: [], + followupQuestions: [], + }, + }); + r.end(); + }); + expect(out).toBe('Hello world\n'); + }); + it('pretty renders agent-response events as concise status lines', () => { const out = captureStdout(() => { const r = createStreamRenderer({ pretty: true }); @@ -379,6 +415,41 @@ describe('createStreamRenderer', () => { }); }); +describe('documentToText', () => { + it('joins block children with newlines and inline text directly, dropping marks', () => { + const doc = { + object: 'document', + nodes: [ + { + object: 'block', + type: 'heading-1', + nodes: [{ object: 'text', leaves: [{ text: 'Title' }] }], + }, + { + object: 'block', + type: 'paragraph', + nodes: [ + { + object: 'text', + leaves: [ + { text: 'A ' }, + { text: 'bold', marks: [{ type: 'bold' }] }, + { text: ' word.' }, + ], + }, + ], + }, + ], + }; + expect(documentToText(doc)).toBe('Title\nA bold word.'); + }); + + it('returns empty string for non-document input', () => { + expect(documentToText(undefined)).toBe(''); + expect(documentToText('x')).toBe('x'); + }); +}); + describe('createRequestTimeout', () => { it('returns a live, un-aborted timeout by default (and clear() stops the timer)', () => { const t = createRequestTimeout(); diff --git a/packages/cli/src/output.ts b/packages/cli/src/output.ts index 9da8a6fab..cf6326b93 100644 --- a/packages/cli/src/output.ts +++ b/packages/cli/src/output.ts @@ -484,15 +484,40 @@ function createPrettyStreamRenderer(): StreamRenderer { }; } -// Renderable text for an answer field. `--format markdown` yields a string; the -// default `document` form has no direct text rendering here, so it is skipped -// (its sources still render as citations once the stream ends). +// Renderable text for an answer field. `--format markdown` yields `{ markdown }`; +// `--format document` (the raw form) yields `{ document }`, a node tree we flatten +// to plain text via documentToText so pretty mode still shows the answer. function streamAnswerText(answer: unknown): string | undefined { if (typeof answer === 'string') return answer; - if (isPlainObject(answer) && typeof answer.markdown === 'string') return answer.markdown; + if (isPlainObject(answer)) { + if (typeof answer.markdown === 'string') return answer.markdown; + if (isPlainObject(answer.document)) return documentToText(answer.document); + } return undefined; } +// Flatten a GitBook document node tree to plain text. Text nodes concatenate their +// `leaves`; block containers join their children — with newlines when those +// children are themselves blocks (paragraphs, headings, list items), directly when +// they're inline text. Marks (bold/italic/links) are dropped; this is a legible +// fallback for pretty mode, not a full markdown serializer. +export function documentToText(node: unknown): string { + if (typeof node === 'string') return node; + if (!isPlainObject(node)) return ''; + if (Array.isArray(node.leaves)) { + return node.leaves + .map((leaf) => (isPlainObject(leaf) && typeof leaf.text === 'string' ? leaf.text : '')) + .join(''); + } + if (Array.isArray(node.nodes)) { + const hasBlockChildren = node.nodes.some( + (child) => isPlainObject(child) && child.object === 'block', + ); + return node.nodes.map(documentToText).join(hasBlockChildren ? '\n' : ''); + } + return ''; +} + // One-line status for an AIStreamResponse event, keyed by its `type`. function streamStatusLine(event: Record): string { const type = String(event.type); From a0d58b0c6440c5040daa0b448bfbd4f594fe935b Mon Sep 17 00:00:00 2001 From: Tal Gluck Date: Fri, 17 Jul 2026 14:53:16 +0200 Subject: [PATCH 11/11] CLI: make the stream renderer generic with per-endpoint event strategies Addresses review feedback: the pretty renderer no longer sniffs event shapes in one monolith. createStreamRenderer now owns only the generic mechanics (NDJSON/ YAML serialization, the sink + mid-line tracking, end-of-stream flush) and delegates per-event rendering to an injectable StreamEventRenderer. The three schemas get dedicated strategies (answer / question / agent-response) plus a generic fallback; the generator selects one per endpoint from the SSE response's event schema (STREAM_RENDERERS), so a new/changed schema means a new strategy at the call site rather than editing the core. --- packages/cli/src/output.test.ts | 38 ++++++- packages/cli/src/output.ts | 188 ++++++++++++++++++++------------ scripts/generate-commands.ts | 39 ++++++- 3 files changed, 188 insertions(+), 77 deletions(-) diff --git a/packages/cli/src/output.test.ts b/packages/cli/src/output.test.ts index 6c18c7916..f5669f322 100644 --- a/packages/cli/src/output.test.ts +++ b/packages/cli/src/output.test.ts @@ -5,6 +5,10 @@ import { CLI_TIMEOUT_MS, coerceArrayQueryParam, coerceBodyFlag, + createAgentResponseStreamRenderer, + createAnswerStreamRenderer, + createGenericStreamRenderer, + createQuestionStreamRenderer, createRequestTimeout, createStreamRenderer, documentToText, @@ -335,7 +339,7 @@ describe('createStreamRenderer', () => { it('pretty renders a recommended-question stream as bullets', () => { const out = captureStdout(() => { - const r = createStreamRenderer({ pretty: true }); + const r = createStreamRenderer({ pretty: true }, createQuestionStreamRenderer()); r.write({ question: 'How do I start?' }); r.write({ question: 'What is X?' }); r.end(); @@ -345,7 +349,7 @@ describe('createStreamRenderer', () => { it('pretty prints answer text incrementally, then sources and follow-ups', () => { const out = captureStdout(() => { - const r = createStreamRenderer({ pretty: true }); + const r = createStreamRenderer({ pretty: true }, createAnswerStreamRenderer()); // Each event carries the answer so far; only the new suffix prints. r.write({ type: 'answer', @@ -368,7 +372,7 @@ describe('createStreamRenderer', () => { it('pretty renders a document-form answer (--format document) as text', () => { const out = captureStdout(() => { - const r = createStreamRenderer({ pretty: true }); + const r = createStreamRenderer({ pretty: true }, createAnswerStreamRenderer()); r.write({ type: 'answer', answer: { @@ -403,7 +407,7 @@ describe('createStreamRenderer', () => { it('pretty renders agent-response events as concise status lines', () => { const out = captureStdout(() => { - const r = createStreamRenderer({ pretty: true }); + const r = createStreamRenderer({ pretty: true }, createAgentResponseStreamRenderer()); r.write({ type: 'response_start', messageId: 'm1' }); r.write({ type: 'response_document', operation: 'insert', blocks: [{}, {}] }); r.write({ type: 'response_finish', messageId: 'm1', response: {} }); @@ -413,6 +417,32 @@ describe('createStreamRenderer', () => { '[response_start]\n[response_document] insert — 2 block(s)\n[response_finish]\n', ); }); + + it('generic renderer (the default) streams strings inline and blocks objects', () => { + const withDefault = captureStdout(() => { + const r = createStreamRenderer({ pretty: true }); // no strategy → generic + r.write('hello '); + r.write('world'); + r.end(); + }); + expect(withDefault).toBe('hello world\n'); + + const explicit = captureStdout(() => { + const r = createStreamRenderer({ pretty: true }, createGenericStreamRenderer()); + r.write({ a: 1 }); + r.end(); + }); + expect(explicit).toBe('a: 1\n'); + }); + + it('machine formats ignore the event renderer (stay schema-agnostic)', () => { + const out = captureStdout(() => { + const r = createStreamRenderer({ json: true }, createAnswerStreamRenderer()); + r.write({ anything: true }); + r.end(); + }); + expect(out).toBe('{"anything":true}\n'); + }); }); describe('documentToText', () => { diff --git a/packages/cli/src/output.ts b/packages/cli/src/output.ts index cf6326b93..ba2ff291b 100644 --- a/packages/cli/src/output.ts +++ b/packages/cli/src/output.ts @@ -377,12 +377,37 @@ export interface StreamRenderer { end(): void; } +// A line-oriented output target handed to StreamEventRenderers. `out` writes +// inline (and may leave the cursor mid-line, e.g. incremental answer text); +// `line` writes a full line. The core tracks the mid-line state so `end` can +// flush a trailing newline before any end-of-stream content. +export interface StreamSink { + out(text: string): void; + line(text: string): void; +} + +// Strategy for rendering ONE family of events in pretty mode. The core renderer +// owns all the generic mechanics (machine-format serialization, the sink and its +// mid-line tracking, calling `end`); a StreamEventRenderer only has to know how to +// turn its own event schema into text. New/changed event schemas mean a new +// strategy passed at the call site — the core never changes. See generate-commands.ts, +// which selects the strategy per endpoint from the response's event-stream schema. +export interface StreamEventRenderer { + write(event: unknown, sink: StreamSink): void; + // Flush end-of-stream content (e.g. citations). Optional; defaults to a no-op. + end?(sink: StreamSink): void; +} + // Machine formats stream one record per event so a consumer can parse the output // incrementally: NDJSON for --json (one compact object per line), a stream of -// YAML documents for --yaml (each introduced by the `---` marker). Pretty mode -// renders progressively (see createPrettyStreamRenderer). Format resolution — and -// the piped-defaults-to-YAML behaviour — is shared with printResult. -export function createStreamRenderer(options: OutputOptions): StreamRenderer { +// YAML documents for --yaml (each introduced by the `---` marker) — both fully +// schema-agnostic. Pretty mode delegates per-event rendering to `eventRenderer` +// (a generic fallback when the caller doesn't supply one). Format resolution — +// and the piped-defaults-to-YAML behaviour — is shared with printResult. +export function createStreamRenderer( + options: OutputOptions, + eventRenderer: StreamEventRenderer = createGenericStreamRenderer(), +): StreamRenderer { const format = resolveFormat(options); if (format === 'json') { return { @@ -397,89 +422,110 @@ export function createStreamRenderer(options: OutputOptions): StreamRenderer { end: () => {}, }; } - return createPrettyStreamRenderer(); -} - -// Human-readable streaming. The three event shapes these endpoints emit are each -// rendered on the fly: -// - recommended-question events (`{ question }`) → one bullet per question -// - answer snapshots (`{ type: 'answer', answer }`) → the answer text, printed -// incrementally as the snapshot grows, then its sources as citations at end -// - agent-response events (`{ type: 'response_*' }`) → a concise status line -// Unknown shapes fall back to a compact block so nothing is silently dropped. -function createPrettyStreamRenderer(): StreamRenderer { - let printedAnswerLen = 0; // chars of the (string) answer already written + let midLine = false; // last write left the cursor mid-line - let sources: unknown[] | null = null; - let followups: unknown[] | null = null; + const sink: StreamSink = { + out(text) { + if (text.length === 0) return; + process.stdout.write(text); + midLine = !text.endsWith('\n'); + }, + line(text) { + process.stdout.write(`${text}\n`); + midLine = false; + }, + }; + return { + write: (event) => eventRenderer.write(event, sink), + end: () => { + if (midLine) { + process.stdout.write('\n'); + midLine = false; + } + eventRenderer.end?.(sink); + }, + }; +} - const out = (s: string) => { - if (s.length === 0) return; - process.stdout.write(s); - midLine = !s.endsWith('\n'); +// Fallback strategy: no schema knowledge. Strings stream inline; objects/scalars +// print as a compact block. Used for any streaming endpoint without a dedicated +// renderer, so a new SSE endpoint is never silently blank. +export function createGenericStreamRenderer(): StreamEventRenderer { + return { + write(event, sink) { + if (typeof event === 'string') sink.out(event); + else if (isPlainObject(event)) sink.line(formatPretty(event)); + else sink.line(formatScalar(event)); + }, }; - const line = (s: string) => { - process.stdout.write(`${s}\n`); - midLine = false; +} + +// `SearchAIRecommendedQuestionStream`: one `{ question }` per event → a bullet. +export function createQuestionStreamRenderer(): StreamEventRenderer { + return { + write(event, sink) { + if (isPlainObject(event) && typeof event.question === 'string') { + sink.line(`• ${event.question}`); + } + }, }; +} +// `SearchAIAnswerStream`: progressive `{ type: 'answer', answer }` snapshots. Each +// event carries the answer so far, so only the new suffix is printed; the latest +// snapshot's sources/follow-ups render as citations once the stream ends. +export function createAnswerStreamRenderer(): StreamEventRenderer { + let printedAnswerLen = 0; + let sources: unknown[] | null = null; + let followups: unknown[] | null = null; return { - write(event: unknown): void { - if (typeof event === 'string') { - out(event); + write(event, sink) { + if (!isPlainObject(event) || event.type !== 'answer' || !isPlainObject(event.answer)) { return; } - if (!isPlainObject(event)) { - line(formatScalar(event)); - return; + const answer = event.answer; + if (Array.isArray(answer.sources)) sources = answer.sources; + if (Array.isArray(answer.followupQuestions)) followups = answer.followupQuestions; + const text = streamAnswerText(answer.answer); + if (text !== undefined) { + if (text.length >= printedAnswerLen) sink.out(text.slice(printedAnswerLen)); + else sink.out(`\n${text}`); // shrank unexpectedly: reprint whole + printedAnswerLen = text.length; } - // Recommended-question stream: one `{ question }` per event. - if (typeof event.question === 'string' && event.type === undefined) { - line(`• ${event.question}`); + }, + end(sink) { + if (sources && sources.length > 0) { + sink.line(''); + sink.line('Sources:'); + for (const s of sources) sink.line(` - ${formatStreamSource(s)}`); + } + if (followups && followups.length > 0) { + sink.line(''); + sink.line('Follow-up questions:'); + for (const q of followups) sink.line(` - ${formatScalar(q)}`); + } + }, + }; +} + +// `AIStreamResponse`: a status line per event, except the JSON-object chunks, +// which are raw text to concatenate. +export function createAgentResponseStreamRenderer(): StreamEventRenderer { + return { + write(event, sink) { + if (!isPlainObject(event)) { + sink.line(formatScalar(event)); return; } - // Answer stream: progressive `{ type: 'answer', answer }` snapshots. - // Each event carries the answer so far, so we print only the new - // suffix; sources/followups from the latest snapshot render at end. - if (event.type === 'answer' && isPlainObject(event.answer)) { - const answer = event.answer; - if (Array.isArray(answer.sources)) sources = answer.sources; - if (Array.isArray(answer.followupQuestions)) followups = answer.followupQuestions; - const text = streamAnswerText(answer.answer); - if (text !== undefined) { - if (text.length >= printedAnswerLen) out(text.slice(printedAnswerLen)); - else out(`\n${text}`); // shrank unexpectedly: reprint whole - printedAnswerLen = text.length; - } + if (typeof event.jsonChunk === 'string') { + sink.out(event.jsonChunk); return; } - // Agent-response stream (AIStreamResponse): a status line per event, - // except the JSON-object chunks which are raw text to concatenate. if (typeof event.type === 'string') { - if (typeof event.jsonChunk === 'string') { - out(event.jsonChunk); - return; - } - line(streamStatusLine(event)); + sink.line(streamStatusLine(event)); return; } - line(formatPretty(event)); - }, - end(): void { - if (midLine) { - process.stdout.write('\n'); - midLine = false; - } - if (sources && sources.length > 0) { - line(''); - line('Sources:'); - for (const s of sources) line(` - ${formatStreamSource(s)}`); - } - if (followups && followups.length > 0) { - line(''); - line('Follow-up questions:'); - for (const q of followups) line(` - ${formatScalar(q)}`); - } + sink.line(formatPretty(event)); }, }; } diff --git a/scripts/generate-commands.ts b/scripts/generate-commands.ts index dc7015b46..0f16a36ea 100644 --- a/scripts/generate-commands.ts +++ b/scripts/generate-commands.ts @@ -288,6 +288,20 @@ function isStreamingOperation(operation: Operation): boolean { return false; } +// The schema name of an SSE operation's event (e.g. `SearchAIAnswerStream`), read +// from the `text/event-stream` response's schema `$ref`. Drives per-endpoint +// renderer selection in emitStreamingCommand. Undefined if not resolvable. +function streamEventSchemaName(operation: Operation): string | undefined { + for (const response of Object.values(operation.responses ?? {})) { + const stream = ( + response.content as Record | undefined + )?.['text/event-stream']; + const ref = stream?.schema?.$ref ?? stream?.schema?.items?.$ref; + if (typeof ref === 'string') return ref.match(/\/([^/]+)$/)?.[1]; + } + return undefined; +} + // ─── Path parsing ───────────────────────────────────────────────────────────-- interface Seg { @@ -557,6 +571,9 @@ interface Route { bodyFlags: BodyFlag[] | null; hasBody: boolean; isStreaming: boolean; // SSE endpoint → emitted via the streaming path + // Schema name of the SSE event (from the text/event-stream response), used to + // pick the pretty StreamEventRenderer. Undefined for non-streaming routes. + streamEventSchema?: string; naming: Naming; } @@ -628,6 +645,7 @@ function buildRoutes(spec: OpenAPISpec): Route[] { bodyFlags, hasBody, isStreaming: isStreamingOperation(operation), + streamEventSchema: streamEventSchemaName(operation), naming: computeNaming( upper, apiPath, @@ -1043,6 +1061,17 @@ function emitSimpleCommand( return lines.join('\n'); } +// Maps an SSE event schema name to the output-module StreamEventRenderer factory +// that knows how to render it in pretty mode. An unmapped schema falls back to the +// generic renderer (createStreamRenderer's default) — so a new streaming endpoint +// is never silently blank, it just gets a generic rendering until a strategy is +// added here. This is the one place endpoint↔renderer wiring lives. +const STREAM_RENDERERS: Record = { + SearchAIAnswerStream: 'createAnswerStreamRenderer', + SearchAIRecommendedQuestionStream: 'createQuestionStreamRenderer', + AIStreamResponse: 'createAgentResponseStreamRenderer', +}; + // SSE endpoint: iterate the event stream through a StreamRenderer instead of the // buffered printResult path. A partial answer stays on screen if the stream errors // mid-flight (renderer.end() flushes first, then the error prints); SIGINT flushes @@ -1069,7 +1098,13 @@ function emitStreamingCommand( if (formatQp) { lines.push(`${I} if (options.format === undefined) query['format'] = 'markdown';`); } - lines.push(`${I} const stream = createStreamRenderer(options);`); + // Pick the pretty renderer for this endpoint's event schema; unmapped schemas + // fall through to createStreamRenderer's generic default. + const rendererFactory = route.streamEventSchema + ? STREAM_RENDERERS[route.streamEventSchema] + : undefined; + const rendererArg = rendererFactory ? `, ${rendererFactory}()` : ''; + lines.push(`${I} const stream = createStreamRenderer(options${rendererArg});`); lines.push(`${I} const timeout = createRequestTimeout();`); lines.push(`${I} const onSigint = () => {`); lines.push(`${I} timeout?.clear();`); @@ -1245,7 +1280,7 @@ function emitFile(tree: GroupNode, completions: Record): string lines.push(`// Output formatting lives in ./output (a real, unit-tested module) rather than`); lines.push(`// being inlined here, so the rendering logic has a single source of truth.`); lines.push( - `import { printResult, coerceBodyFlag, coerceArrayQueryParam, explainApiError, normalizeChangesMarkdown, createStreamRenderer, createRequestTimeout, explainTimeout } from './output';`, + `import { printResult, coerceBodyFlag, coerceArrayQueryParam, explainApiError, normalizeChangesMarkdown, createStreamRenderer, createAnswerStreamRenderer, createQuestionStreamRenderer, createAgentResponseStreamRenderer, createRequestTimeout, explainTimeout } from './output';`, ); lines.push(``); lines.push(`export function registerGeneratedCommands(program: Command): void {`);