Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
7756334
CLI: include hand-written commands in shell completion (RND-12064)
talagluck Jul 16, 2026
024f776
CLI: nested, sorted command tree in --help + integrations pointer (RN…
talagluck Jul 16, 2026
c9d3f26
Merge branch 'main' into cli-help-discoverability
talagluck Jul 16, 2026
292ffc9
CLI: accept path params as both positionals and --flags (RND-12046)
talagluck Jul 16, 2026
c93a2db
CLI: add the 5 SSE streaming endpoints (RND-12044)
talagluck Jul 16, 2026
d4b4d75
CLI: fall back to spec-derived OAuth scopes when discovery omits them…
talagluck Jul 16, 2026
dd28f80
Add changeset for CLI generator internals (RND-12046, RND-12044, RND-…
talagluck Jul 16, 2026
0f77fa9
CLI: add an idle request timeout to generated commands
talagluck Jul 16, 2026
c230bf1
Merge branch 'main' into cli-help-discoverability
talagluck Jul 16, 2026
7c9060b
Merge branch 'cli-help-discoverability' into cli-generator-internals
talagluck Jul 16, 2026
90a06f0
Fix prettier formatting in generator and tests
talagluck Jul 16, 2026
554551a
Merge branch 'main' into cli-generator-internals
talagluck Jul 16, 2026
6254885
CLI: default ask-stream format to markdown so pretty mode shows the a…
talagluck Jul 16, 2026
ffd20a4
CLI: render document-form ask answers in pretty mode
talagluck Jul 16, 2026
d606f40
Merge branch 'main' into cli-generator-internals
talagluck Jul 17, 2026
d734c56
Merge branch 'main' into cli-generator-internals
talagluck Jul 17, 2026
a0d58b0
CLI: make the stream renderer generic with per-endpoint event strategies
talagluck Jul 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cli-generator-internals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@gitbook/cli': patch
---

Generated commands now accept each path parameter as both a positional argument and a `--<name>` 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`.
1 change: 1 addition & 0 deletions packages/cli/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 7 additions & 2 deletions packages/cli/src/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
Expand Down
220 changes: 220 additions & 0 deletions packages/cli/src/output.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,21 @@ import { describe, it, expect } from 'bun:test';

import {
asCollection,
CLI_TIMEOUT_MS,
coerceArrayQueryParam,
coerceBodyFlag,
createAgentResponseStreamRenderer,
createAnswerStreamRenderer,
createGenericStreamRenderer,
createQuestionStreamRenderer,
createRequestTimeout,
createStreamRenderer,
documentToText,
explainApiError,
explainTimeout,
formatCollection,
formatObjectSummary,
formatStreamSource,
isPlainObject,
normalizeChangesMarkdown,
normalizeMarkdown,
Expand All @@ -15,6 +25,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');
Expand Down Expand Up @@ -289,3 +315,197 @@ 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 }, createQuestionStreamRenderer());
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 }, createAnswerStreamRenderer());
// 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 a document-form answer (--format document) as text', () => {
const out = captureStdout(() => {
const r = createStreamRenderer({ pretty: true }, createAnswerStreamRenderer());
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 }, createAgentResponseStreamRenderer());
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',
);
});

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', () => {
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();
// 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('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',
);
});
});
Loading
Loading