Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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-help-discoverability.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 18 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <endpoint>', GITBOOK_DEFAULT_ENDPOINT)
Expand Down Expand Up @@ -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);
Expand Down
139 changes: 139 additions & 0 deletions packages/cli/src/help-tree.ts
Original file line number Diff line number Diff line change
@@ -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);
}
34 changes: 21 additions & 13 deletions scripts/generate-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string[]> = {
'': ['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([
Expand Down Expand Up @@ -1123,19 +1136,14 @@ function completionMap(commands: Command[]): Map<string, string[]> {

function generateCompletions(commands: Command[]): Record<string, string> {
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 <TAB>`, `gitbook openapi <TAB>`, 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
Expand Down
Loading