From 072d04546a9fc19ed97c0b6ab548d001ffb378e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Chalifour?= Date: Mon, 13 Jul 2026 14:06:28 +0200 Subject: [PATCH] feat(cli): redesign help across command tree Replace Citty's default help panels with a shared renderer for the root command and every nested command. The previous output was flat, inconsistent between root and subcommands, and fragile across terminal widths. Derive command listings from command metadata, including platform/ingest/query groups and hidden-command filtering. Render Usage, Arguments, Options, Commands, guidance, and Examples consistently with responsive hanging indentation, existing terminal styles, command highlighting, aliases, enum hints, required/default annotations, and narrow-terminal wrapping. Keep command-specific examples and active-context behavior intact while removing the Citty usage renderer entirely. Add focused coverage for grouped root help, nested panels, responsive wrapping, hidden commands, and command-colored guidance. --- cli/src/cli.ts | 2 +- cli/src/commands/api.ts | 1 + cli/src/commands/catalogs.ts | 1 + cli/src/commands/completion.ts | 1 + cli/src/commands/duckdb.ts | 1 + cli/src/commands/lakehouse/append.ts | 1 + cli/src/commands/lakehouse/query.ts | 1 + cli/src/commands/lakehouse/schema.ts | 1 + cli/src/commands/lakehouse/upload.ts | 1 + cli/src/commands/lakehouse/upsert.ts | 1 + cli/src/commands/login.ts | 2 + cli/src/commands/profile.ts | 1 + cli/src/commands/update.ts | 1 + cli/src/lib/citty-usage.ts | 181 ---------- cli/src/lib/command-context.ts | 3 + cli/src/lib/usage.ts | 479 +++++++++++++++++++++++++++ cli/tests/citty-usage.test.ts | 113 ------- cli/tests/usage.test.ts | 200 +++++++++++ 18 files changed, 696 insertions(+), 295 deletions(-) delete mode 100644 cli/src/lib/citty-usage.ts create mode 100644 cli/src/lib/usage.ts delete mode 100644 cli/tests/citty-usage.test.ts create mode 100644 cli/tests/usage.test.ts diff --git a/cli/src/cli.ts b/cli/src/cli.ts index a2af3af..bb161dc 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -36,7 +36,7 @@ import { resolveSubCommandForUsage, showAltertableUsage, showCommandExamplesForArgs, -} from "@/lib/citty-usage.ts"; +} from "@/lib/usage.ts"; import { findFirstPositionalToken, valueFlagsFor } from "@/lib/command-delegation.ts"; import { findEarlyBootstrapExit } from "@/lib/early-bootstrap.ts"; import { terminalError, applyTerminalColorFromContext } from "@/ui/terminal/styles.ts"; diff --git a/cli/src/commands/api.ts b/cli/src/commands/api.ts index a1462dc..90f4e69 100644 --- a/cli/src/commands/api.ts +++ b/cli/src/commands/api.ts @@ -258,6 +258,7 @@ export const apiCommand = defineOperationCommand({ capabilities: ["management-http"], meta: { name: "duckdb", + commandGroup: "query", description: "Open a DuckDB shell attached to lakehouse catalogs (all of them by default).", examples: ["altertable duckdb", "altertable duckdb my_catalog"], }, diff --git a/cli/src/commands/lakehouse/append.ts b/cli/src/commands/lakehouse/append.ts index 5d92860..b7f46d8 100644 --- a/cli/src/commands/lakehouse/append.ts +++ b/cli/src/commands/lakehouse/append.ts @@ -87,6 +87,7 @@ const appendStatusCommand = defineHttpCommand({ export const appendCommand = defineGroupCommand({ meta: { name: "append", + commandGroup: "ingest", description: "Append JSON rows to a table.", examples: [ "altertable append --catalog db --schema public --table events --data '[{\"id\":1}]'", diff --git a/cli/src/commands/lakehouse/query.ts b/cli/src/commands/lakehouse/query.ts index 2467ba8..ecdc03c 100644 --- a/cli/src/commands/lakehouse/query.ts +++ b/cli/src/commands/lakehouse/query.ts @@ -184,6 +184,7 @@ const QUERY_SUBCOMMAND_NAMES = new Set(Object.keys(querySubCommands)); export const queryCommand = defineGroupCommand({ meta: { name: "query", + commandGroup: "query", description: "Run SQL queries against the lakehouse.", examples: [ 'altertable query "SELECT * FROM users LIMIT 10"', diff --git a/cli/src/commands/lakehouse/schema.ts b/cli/src/commands/lakehouse/schema.ts index 435deed..fb8865d 100644 --- a/cli/src/commands/lakehouse/schema.ts +++ b/cli/src/commands/lakehouse/schema.ts @@ -74,6 +74,7 @@ export const schemaCommand = defineOperationCommand, void>( output: "none", meta: { name: "profile", + commandGroup: "platform", description: "Manage named profiles and stored credentials.", examples: [ "altertable profile show", diff --git a/cli/src/commands/update.ts b/cli/src/commands/update.ts index aac546c..a2a6fa2 100644 --- a/cli/src/commands/update.ts +++ b/cli/src/commands/update.ts @@ -137,6 +137,7 @@ async function runUpdateCommand(args: UpdateCommandArgs, sink: OutputSink): Prom export const updateCommand = defineAltertableCommand({ meta: { name: "update", + commandGroup: "platform", description: "Check for a newer Altertable CLI release and optionally install it.", examples: [ "altertable update", diff --git a/cli/src/lib/citty-usage.ts b/cli/src/lib/citty-usage.ts deleted file mode 100644 index ad76f0a..0000000 --- a/cli/src/lib/citty-usage.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { renderUsage, type ArgsDef, type CommandDef } from "citty"; -import type { AltertableCommandMeta } from "@/lib/command-context.ts"; -import { formatCommandExamplesSection } from "@/ui/terminal/styles.ts"; - -function toArray(value: T | T[] | undefined): T[] { - if (Array.isArray(value)) { - return value; - } - return value === undefined ? [] : [value]; -} - -function camelCase(input: string): string { - return input.replace(/-([a-z])/g, (_, character: string) => character.toUpperCase()); -} - -async function resolveValue(input: T | (() => T) | (() => Promise) | Promise): Promise { - if (typeof input === "function") { - return await (input as () => T | Promise)(); - } - return await input; -} - -function isValueFlag(flag: string, argsDef: ArgsDef): boolean { - const name = flag.replace(/^-{1,2}/, ""); - const normalized = camelCase(name); - for (const [key, definition] of Object.entries(argsDef)) { - if (definition.type !== "string" && definition.type !== "enum") { - continue; - } - if (normalized === camelCase(key)) { - return true; - } - const aliases = Array.isArray(definition.alias) - ? definition.alias - : definition.alias - ? [definition.alias] - : []; - if (aliases.includes(name)) { - return true; - } - } - return false; -} - -function findSubCommandIndex(rawArgs: string[], argsDef: ArgsDef): number { - for (let index = 0; index < rawArgs.length; index += 1) { - const arg = rawArgs[index]; - if (arg === undefined) { - continue; - } - if (arg === "--") { - return -1; - } - if (arg.startsWith("-")) { - if (!arg.includes("=") && isValueFlag(arg, argsDef)) { - index += 1; - } - continue; - } - return index; - } - return -1; -} - -async function findSubCommand( - subCommands: Record< - string, - CommandDef | (() => CommandDef) | (() => Promise) | Promise - >, - name: string | undefined, -): Promise { - if (!name) { - return undefined; - } - if (name in subCommands) { - return await resolveValue(subCommands[name]); - } - for (const subCommand of Object.values(subCommands)) { - const resolved = await resolveValue(subCommand); - const meta = await resolveValue(resolved.meta); - if (meta?.alias && toArray(meta.alias).includes(name)) { - return resolved; - } - } - return undefined; -} - -export async function resolveSubCommandForUsage( - command: CommandDef, - rawArgs: string[], - parent?: CommandDef, -): Promise<[CommandDef, CommandDef | undefined]> { - const subCommands = await resolveValue(command.subCommands); - if (subCommands && Object.keys(subCommands).length > 0) { - const subCommandArgIndex = findSubCommandIndex(rawArgs, await resolveValue(command.args ?? {})); - const subCommandName = rawArgs[subCommandArgIndex]; - const subCommand = await findSubCommand(subCommands, subCommandName); - if (subCommand) { - return resolveSubCommandForUsage(subCommand, rawArgs.slice(subCommandArgIndex + 1), command); - } - } - return [command, parent]; -} - -async function resolveCommandMeta(command: CommandDef): Promise { - return (await resolveValue(command.meta ?? {})) as AltertableCommandMeta; -} - -async function resolveCommandExamples(command: CommandDef): Promise { - const meta = await resolveCommandMeta(command); - return meta.examples ?? []; -} - -async function isHiddenCommand(command: CommandDef): Promise { - const meta = await resolveCommandMeta(command); - return meta.hidden === true; -} - -async function commandForVisibleUsage(command: CommandDef): Promise { - const subCommands = await resolveValue(command.subCommands); - if (!subCommands || Object.keys(subCommands).length === 0) { - return command; - } - - const visibleEntries: [string, CommandDef][] = []; - for (const [name, subCommand] of Object.entries(subCommands)) { - const resolved = await resolveValue(subCommand); - if (!(await isHiddenCommand(resolved))) { - visibleEntries.push([name, resolved]); - } - } - - return { - ...command, - subCommands: Object.fromEntries(visibleEntries), - }; -} - -const ANSI_FOREGROUND_RESET = `${String.fromCharCode(27)}[39m`; - -// Citty appends " (commandName)" to meta.description; the USAGE line already names the command. -function stripCittyDescriptionSuffix(usage: string): string { - const firstNewline = usage.indexOf("\n"); - const firstLine = firstNewline === -1 ? usage : usage.slice(0, firstNewline); - const rest = firstNewline === -1 ? "" : usage.slice(firstNewline); - const hasColorReset = firstLine.endsWith(ANSI_FOREGROUND_RESET); - const plainFirstLine = hasColorReset - ? firstLine.slice(0, -ANSI_FOREGROUND_RESET.length) - : firstLine; - const strippedFirstLine = plainFirstLine.replace(/ \([^)]+\)$/, ""); - return `${strippedFirstLine}${hasColorReset ? ANSI_FOREGROUND_RESET : ""}${rest}`; -} - -export async function renderAltertableUsage( - command: CommandDef, - parent?: CommandDef, -): Promise { - const usageCommand = await commandForVisibleUsage(command); - const usage = stripCittyDescriptionSuffix(await renderUsage(usageCommand, parent)); - const examplesSection = formatCommandExamplesSection(await resolveCommandExamples(command)); - if (examplesSection.length === 0) { - return usage; - } - return `${usage}${examplesSection}`; -} - -export async function showAltertableUsage(command: CommandDef, parent?: CommandDef): Promise { - console.log(`${await renderAltertableUsage(command, parent)}\n`); -} - -export async function showCommandExamplesForArgs( - root: CommandDef, - rawArgs: string[], -): Promise { - const [command] = await resolveSubCommandForUsage(root, rawArgs); - const section = formatCommandExamplesSection(await resolveCommandExamples(command)); - if (section.length === 0) { - return; - } - console.error(`${section.trimStart()}\n`); -} diff --git a/cli/src/lib/command-context.ts b/cli/src/lib/command-context.ts index d775338..73a0b3c 100644 --- a/cli/src/lib/command-context.ts +++ b/cli/src/lib/command-context.ts @@ -2,9 +2,12 @@ import type { ArgsDef, CommandContext, CommandDef, CommandMeta, Resolvable } fro import type { CliRuntime, OutputSink } from "@/lib/runtime.ts"; import { getCliRuntime } from "@/lib/runtime.ts"; +export type AltertableCommandGroup = "platform" | "ingest" | "query"; + export type AltertableCommandMeta = CommandMeta & { examples?: readonly string[]; hidden?: boolean; + commandGroup?: AltertableCommandGroup; }; export type CommandRunContext = CommandContext & { diff --git a/cli/src/lib/usage.ts b/cli/src/lib/usage.ts new file mode 100644 index 0000000..975dcfc --- /dev/null +++ b/cli/src/lib/usage.ts @@ -0,0 +1,479 @@ +import type { ArgDef, ArgsDef, CommandDef } from "citty"; +import type { AltertableCommandGroup, AltertableCommandMeta } from "@/lib/command-context.ts"; +import { + formatCommandExamplesSection, + getVisibleTextWidth, + terminalAccent, + terminalHighlightCommands, + terminalMuted, + terminalStrong, +} from "@/ui/terminal/styles.ts"; +import { HELP_FLAGS, VERSION_FLAGS } from "@/lib/early-bootstrap.ts"; + +const HELP_INDENT = " "; +const HELP_COLUMN_GAP = " "; +const HELP_MIN_DESCRIPTION_WIDTH = 16; +const HELP_FALLBACK_TERMINAL_WIDTH = 68; +const COMMAND_GROUP_TITLES: Record = { + platform: "Platform", + ingest: "Ingest", + query: "Query", +}; + +function toArray(value: T | T[] | undefined): T[] { + if (Array.isArray(value)) { + return value; + } + return value === undefined ? [] : [value]; +} + +function camelCase(input: string): string { + return input.replace(/-([a-z])/g, (_, character: string) => character.toUpperCase()); +} + +async function resolveValue(input: T | (() => T) | (() => Promise) | Promise): Promise { + if (typeof input === "function") { + return await (input as () => T | Promise)(); + } + return await input; +} + +function isValueFlag(flag: string, argsDef: ArgsDef): boolean { + const name = flag.replace(/^-{1,2}/, ""); + const normalized = camelCase(name); + for (const [key, definition] of Object.entries(argsDef)) { + if (definition.type !== "string" && definition.type !== "enum") { + continue; + } + if (normalized === camelCase(key)) { + return true; + } + const aliases = Array.isArray(definition.alias) + ? definition.alias + : definition.alias + ? [definition.alias] + : []; + if (aliases.includes(name)) { + return true; + } + } + return false; +} + +function findSubCommandIndex(rawArgs: string[], argsDef: ArgsDef): number { + for (let index = 0; index < rawArgs.length; index += 1) { + const arg = rawArgs[index]; + if (arg === undefined) { + continue; + } + if (arg === "--") { + return -1; + } + if (arg.startsWith("-")) { + if (!arg.includes("=") && isValueFlag(arg, argsDef)) { + index += 1; + } + continue; + } + return index; + } + return -1; +} + +async function findSubCommand( + subCommands: Record< + string, + CommandDef | (() => CommandDef) | (() => Promise) | Promise + >, + name: string | undefined, +): Promise { + if (!name) { + return undefined; + } + if (name in subCommands) { + return await resolveValue(subCommands[name]); + } + for (const subCommand of Object.values(subCommands)) { + const resolved = await resolveValue(subCommand); + const meta = await resolveValue(resolved.meta); + if (meta?.alias && toArray(meta.alias).includes(name)) { + return resolved; + } + } + return undefined; +} + +export async function resolveSubCommandForUsage( + command: CommandDef, + rawArgs: string[], + parent?: CommandDef, +): Promise<[CommandDef, CommandDef | undefined]> { + const subCommands = await resolveValue(command.subCommands); + if (subCommands && Object.keys(subCommands).length > 0) { + const subCommandArgIndex = findSubCommandIndex(rawArgs, await resolveValue(command.args ?? {})); + const subCommandName = rawArgs[subCommandArgIndex]; + const subCommand = await findSubCommand(subCommands, subCommandName); + if (subCommand) { + return resolveSubCommandForUsage(subCommand, rawArgs.slice(subCommandArgIndex + 1), command); + } + } + return [command, parent]; +} + +async function resolveCommandMeta(command: CommandDef): Promise { + return (await resolveValue(command.meta ?? {})) as AltertableCommandMeta; +} + +async function resolveCommandExamples(command: CommandDef): Promise { + const meta = await resolveCommandMeta(command); + return meta.examples ?? []; +} + +type VisibleSubCommand = { + name: string; + meta: AltertableCommandMeta; +}; + +async function visibleSubCommands(command: CommandDef): Promise { + const subCommands = await resolveValue(command.subCommands); + if (!subCommands || Object.keys(subCommands).length === 0) { + return []; + } + + const visibleEntries: VisibleSubCommand[] = []; + for (const [name, subCommand] of Object.entries(subCommands)) { + const resolved = await resolveValue(subCommand); + const meta = await resolveCommandMeta(resolved); + if (!meta.hidden) { + visibleEntries.push({ name, meta }); + } + } + + return visibleEntries; +} + +type HelpEntry = { + label: string; + description: string; +}; + +function wrapHelpText(text: string, maxWidth: number): string[] { + maxWidth = Math.max(1, maxWidth); + const normalizedText = text.trim().replace(/\s+/g, " "); + if (normalizedText.length === 0) { + return [""]; + } + + const lines: string[] = []; + let currentLine = ""; + for (const word of normalizedText.split(" ")) { + if (getVisibleTextWidth(word) > maxWidth) { + if (currentLine.length > 0) { + lines.push(currentLine); + currentLine = ""; + } + let remainingWord = word; + while (getVisibleTextWidth(remainingWord) > maxWidth) { + let splitAt = maxWidth; + while (splitAt > 1 && getVisibleTextWidth(remainingWord.slice(0, splitAt)) > maxWidth) { + splitAt -= 1; + } + lines.push(remainingWord.slice(0, splitAt)); + remainingWord = remainingWord.slice(splitAt); + } + currentLine = remainingWord; + continue; + } + + const candidate = currentLine.length === 0 ? word : `${currentLine} ${word}`; + if (currentLine.length > 0 && getVisibleTextWidth(candidate) > maxWidth) { + lines.push(currentLine); + currentLine = word; + } else { + currentLine = candidate; + } + } + if (currentLine.length > 0) { + lines.push(currentLine); + } + return lines; +} + +function getHelpTerminalWidth(): number { + const columns = globalThis.process?.stdout?.columns; + if (typeof columns === "number" && columns > 0) { + return columns; + } + + const environmentColumns = Number.parseInt(globalThis.process?.env?.COLUMNS ?? "", 10); + return Number.isFinite(environmentColumns) && environmentColumns > 0 + ? environmentColumns + : HELP_FALLBACK_TERMINAL_WIDTH; +} + +function formatHelpEntries(entries: readonly HelpEntry[]): string[] { + const labelWidth = Math.max(...entries.map((entry) => getVisibleTextWidth(entry.label)), 0); + const descriptionColumn = HELP_INDENT.length + labelWidth + HELP_COLUMN_GAP.length; + const terminalWidth = getHelpTerminalWidth(); + const descriptionWidth = terminalWidth - descriptionColumn; + const stacked = descriptionWidth < HELP_MIN_DESCRIPTION_WIDTH; + + return entries.flatMap(({ label, description }) => { + const availableWidth = Math.max( + 1, + stacked ? terminalWidth - HELP_INDENT.length : descriptionWidth, + ); + const wrappedDescription = wrapHelpText(description, availableWidth); + if (stacked) { + const wrappedLabel = wrapHelpText(label, availableWidth); + return [ + ...wrappedLabel.map((line) => `${HELP_INDENT}${terminalAccent(line)}`), + ...wrappedDescription.map((line) => `${HELP_INDENT}${line}`), + ]; + } + + return wrappedDescription.map((line, index) => { + if (index === 0) { + return `${HELP_INDENT}${terminalAccent(label.padEnd(labelWidth))}${HELP_COLUMN_GAP}${line}`; + } + return `${" ".repeat(descriptionColumn)}${line}`; + }); + }); +} + +function formatHelpParagraph(text: string, indent: string): string[] { + const width = Math.max(1, getHelpTerminalWidth() - indent.length); + return wrapHelpText(text, width).map((line) => `${indent}${line}`); +} + +function formatHelpGuidance(commandName: string): string[] { + const invocation = `${commandName} --help`; + const guidance = `Use ${invocation} for more information about a command.`; + return formatHelpParagraph(guidance, HELP_INDENT).map((line) => { + const invocationStart = line.indexOf(invocation); + if (invocationStart === -1) { + return line; + } + return `${line.slice(0, invocationStart)}${terminalAccent(invocation)}${line.slice(invocationStart + invocation.length)}`; + }); +} + +function valueHint(name: string, definition: ArgDef): string | undefined { + if (definition.type === "enum" && definition.options?.length) { + return definition.valueHint ?? definition.options.join("|"); + } + if (definition.type === "string") { + return definition.valueHint ?? name; + } + return undefined; +} + +function flagLabel(name: string, definition: ArgsDef[string]): string { + const aliases = ("alias" in definition ? toArray(definition.alias) : []).map( + (alias) => `-${alias}`, + ); + const longFlag = `--${name}`; + const hint = valueHint(name, definition); + const value = hint ? ` <${hint}>` : ""; + return [...aliases, `${longFlag}${value}`].join(", "); +} + +function positionalLabel(name: string, definition: ArgDef): string { + return (definition.valueHint ?? name).toUpperCase(); +} + +function argumentDescription(definition: ArgDef): string { + const required = + definition.default === undefined && + (definition.type === "positional" + ? definition.required !== false + : definition.required === true); + const defaultValue = + definition.default === undefined ? undefined : `(Default: ${definition.default})`; + return [definition.description, required ? "(Required)" : undefined, defaultValue] + .filter((part): part is string => part !== undefined) + .join(" "); +} + +function usageCommandName(meta: AltertableCommandMeta, parentMeta?: AltertableCommandMeta): string { + const name = meta.name ?? "command"; + if (!parentMeta) { + return name; + } + if (parentMeta.name === "altertable") { + return `altertable ${name}`; + } + return `altertable ${parentMeta.name ?? "command"} ${name}`; +} + +function usageTokens( + commandName: string, + args: ArgsDef, + subCommands: readonly VisibleSubCommand[], +): string { + const positionalArgs = Object.entries(args) + .filter(([, definition]) => definition.type === "positional") + .map(([name, definition]) => { + const label = positionalLabel(name, definition); + const required = definition.required !== false && definition.default === undefined; + return required ? `<${label}>` : `[${label}]`; + }); + const hasOptions = Object.values(args).some((definition) => definition.type !== "positional"); + const commandNames = subCommands.map(({ name }) => name).join("|"); + return [ + commandName, + hasOptions ? "[flags]" : undefined, + ...positionalArgs, + commandNames || undefined, + ] + .filter((token): token is string => token !== undefined) + .join(" "); +} + +async function renderCommandUsage(command: CommandDef, parent?: CommandDef): Promise { + const meta = await resolveCommandMeta(command); + const parentMeta = parent ? await resolveCommandMeta(parent) : undefined; + const args = await resolveValue(command.args ?? {}); + const subCommands = await visibleSubCommands(command); + const commandName = usageCommandName(meta, parentMeta); + const positionalEntries = Object.entries(args) + .filter(([, definition]) => definition.type === "positional") + .map(([name, definition]) => ({ + label: positionalLabel(name, definition), + description: argumentDescription(definition), + })); + const optionEntries = Object.entries(args) + .filter(([, definition]) => definition.type !== "positional") + .map(([name, definition]) => ({ + label: flagLabel(name, definition), + description: argumentDescription(definition), + })); + const commandEntries = subCommands.map(({ name, meta: subCommandMeta }) => ({ + label: [name, ...toArray(subCommandMeta.alias)].join(", "), + description: subCommandMeta.description ?? "", + })); + const lines = [ + ...(meta.description ? wrapHelpText(meta.description, getHelpTerminalWidth()) : []), + "", + ` ${terminalStrong("Usage")}`, + ` ${usageTokens(commandName, args, subCommands)}`, + ]; + + if (positionalEntries.length > 0) { + lines.push("", ` ${terminalStrong("Arguments")}`, ...formatHelpEntries(positionalEntries)); + } + if (optionEntries.length > 0) { + lines.push("", ` ${terminalStrong("Options")}`, ...formatHelpEntries(optionEntries)); + } + if (commandEntries.length > 0) { + lines.push("", ` ${terminalStrong("Commands")}`, ...formatHelpEntries(commandEntries)); + lines.push("", ...formatHelpGuidance(commandName)); + } + + const examples = await resolveCommandExamples(command); + if (examples.length > 0) { + lines.push( + "", + ` ${terminalStrong("Examples")}`, + ...examples.flatMap((example) => + formatHelpParagraph(example, HELP_INDENT).map(terminalHighlightCommands), + ), + ); + } + + return lines.join("\n"); +} + +async function renderRootUsage(command: CommandDef, meta: AltertableCommandMeta): Promise { + const args = await resolveValue(command.args ?? {}); + const groupedEntries: Record = { + platform: [], + ingest: [], + query: [], + }; + const lines = [ + ...wrapHelpText(meta.description ?? "", getHelpTerminalWidth()), + "", + ` ${terminalStrong("Usage")}`, + " altertable [flags]", + "", + ` ${terminalStrong("Commands")}`, + ]; + + for (const { name, meta: commandMeta } of await visibleSubCommands(command)) { + if (!commandMeta.commandGroup) { + continue; + } + groupedEntries[commandMeta.commandGroup].push({ + label: name, + description: commandMeta.description ?? "", + }); + } + + let renderedGroup = false; + for (const [group, entries] of Object.entries(groupedEntries) as Array< + [AltertableCommandGroup, HelpEntry[]] + >) { + if (entries.length > 0) { + lines.push( + ...(renderedGroup ? [""] : []), + ` ${terminalMuted(COMMAND_GROUP_TITLES[group])}`, + ...formatHelpEntries(entries), + ); + renderedGroup = true; + } + } + + lines.push("", ...formatHelpGuidance("altertable")); + + const flags = Object.entries(args).map(([name, definition]) => ({ + label: flagLabel(name, definition), + description: definition.description ?? "", + })); + flags.push( + { label: HELP_FLAGS.join(", "), description: "Show this help" }, + { label: VERSION_FLAGS.join(", "), description: "Show the Altertable CLI version" }, + ); + lines.push("", ` ${terminalStrong("Global flags")}`, ...formatHelpEntries(flags)); + + const examples = await resolveCommandExamples(command); + if (examples.length > 0) { + lines.push( + "", + ` ${terminalStrong("Examples")}`, + ...examples.flatMap((example) => + formatHelpParagraph(example, HELP_INDENT).map(terminalHighlightCommands), + ), + ); + } + + return lines.join("\n"); +} + +export async function renderAltertableUsage( + command: CommandDef, + parent?: CommandDef, +): Promise { + const meta = await resolveCommandMeta(command); + if (parent === undefined && meta.name === "altertable") { + return renderRootUsage(command, meta); + } + + return renderCommandUsage(command, parent); +} + +export async function showAltertableUsage(command: CommandDef, parent?: CommandDef): Promise { + console.log(`${await renderAltertableUsage(command, parent)}\n`); +} + +export async function showCommandExamplesForArgs( + root: CommandDef, + rawArgs: string[], +): Promise { + const [command] = await resolveSubCommandForUsage(root, rawArgs); + const section = formatCommandExamplesSection(await resolveCommandExamples(command)); + if (section.length === 0) { + return; + } + console.error(`${section.trimStart()}\n`); +} diff --git a/cli/tests/citty-usage.test.ts b/cli/tests/citty-usage.test.ts deleted file mode 100644 index 66fb0c2..0000000 --- a/cli/tests/citty-usage.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { buildMainCommand } from "@/cli.ts"; -import { getBootstrapCliContext } from "@/context.ts"; -import { defineAltertableCommand } from "@/lib/command-context.ts"; -import { renderAltertableUsage, resolveSubCommandForUsage } from "@/lib/citty-usage.ts"; -import { configureClearAll, configureRunSet } from "@/lib/profile-configure-core.ts"; -import { createCliRuntime, runWithCliRuntime, setCliRuntime } from "@/lib/runtime.ts"; -import { formatCommandExamplesSection } from "@/ui/terminal/styles.ts"; -import { - restoreTerminalState, - snapshotTerminalState, - type TerminalTestState, -} from "@tests/terminal-test-utils.ts"; - -describe("formatCommandExamplesSection", () => { - test("returns empty string when there are no examples", () => { - expect(formatCommandExamplesSection([])).toBe(""); - }); - - test("renders an EXAMPLES section with indented lines", () => { - const section = formatCommandExamplesSection([ - "altertable api /whoami", - "altertable api routes", - ]); - expect(section).toContain("EXAMPLES"); - expect(section).toContain("altertable api /whoami"); - expect(section).toContain("altertable api routes"); - }); -}); - -describe("renderAltertableUsage", () => { - test("appends command examples to citty usage output", async () => { - const command = defineAltertableCommand({ - meta: { - name: "demo", - description: "Demo command.", - examples: ["altertable demo --flag value"], - }, - }); - - const usage = await renderAltertableUsage(command); - expect(usage).toContain("Demo command."); - expect(usage).not.toContain("(demo)"); - expect(usage).toContain("EXAMPLES"); - expect(usage).toContain("altertable demo --flag value"); - }); - - test("hides advanced subcommands from summary usage", async () => { - const command = defineAltertableCommand({ - meta: { name: "demo", description: "Demo command." }, - subCommands: { - visible: { meta: { name: "visible", description: "Visible command" } }, - advanced: { - meta: { name: "advanced", description: "Advanced command", hidden: true }, - }, - }, - }); - - const usage = await renderAltertableUsage(command); - expect(usage).toContain("visible"); - expect(usage).not.toContain("advanced"); - }); - - test("resolves api command examples for unknown endpoint errors", async () => { - const main = buildMainCommand(); - const [command] = await resolveSubCommandForUsage(main, ["api", "/environments/production"]); - const usage = await renderAltertableUsage(command, main); - - expect(usage).toContain("altertable api /whoami"); - expect(usage).toContain("altertable api GET /environments/production/connections"); - }); -}); - -describe("renderAltertableUsage active context", () => { - let testHome = ""; - let terminalState: TerminalTestState; - - beforeEach(() => { - terminalState = snapshotTerminalState(); - testHome = mkdtempSync(join(tmpdir(), "altertable-citty-usage-test-")); - }); - - afterEach(() => { - restoreTerminalState(terminalState); - runWithCliRuntime(createCliRuntime(getBootstrapCliContext()), () => { - process.env.ALTERTABLE_CONFIG_HOME = testHome; - process.env.ALTERTABLE_SECRET_BACKEND = "file"; - configureClearAll("default"); - }); - rmSync(testHome, { recursive: true, force: true }); - delete process.env.ALTERTABLE_CONFIG_HOME; - delete process.env.ALTERTABLE_SECRET_BACKEND; - }); - - test("omits active context on subcommand help", async () => { - Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); - process.env.ALTERTABLE_CONFIG_HOME = testHome; - process.env.ALTERTABLE_SECRET_BACKEND = "file"; - - const runtime = createCliRuntime(getBootstrapCliContext()); - await runWithCliRuntime(runtime, async () => { - await configureRunSet({ apiKey: "atm_test", env: "production" }); - setCliRuntime(runtime); - const main = buildMainCommand(); - const [command] = await resolveSubCommandForUsage(main, ["query"]); - const usage = await renderAltertableUsage(command, main); - expect(usage).not.toContain("ENV"); - }); - }); -}); diff --git a/cli/tests/usage.test.ts b/cli/tests/usage.test.ts new file mode 100644 index 0000000..7f2f306 --- /dev/null +++ b/cli/tests/usage.test.ts @@ -0,0 +1,200 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildMainCommand } from "@/cli.ts"; +import { getBootstrapCliContext } from "@/context.ts"; +import { defineAltertableCommand } from "@/lib/command-context.ts"; +import { renderAltertableUsage, resolveSubCommandForUsage } from "@/lib/usage.ts"; +import { configureClearAll, configureRunSet } from "@/lib/profile-configure-core.ts"; +import { createCliRuntime, runWithCliRuntime, setCliRuntime } from "@/lib/runtime.ts"; +import { VERSION } from "@/version.ts"; +import { getVisibleTextWidth, terminalAccent } from "@/ui/terminal/styles.ts"; +import { + forceTerminalColorForTests, + restoreTerminalState, + snapshotTerminalState, + type TerminalTestState, +} from "@tests/terminal-test-utils.ts"; + +describe("renderAltertableUsage", () => { + test("groups root commands before global flags in a custom help panel", async () => { + const usage = await renderAltertableUsage(buildMainCommand()); + + expect(usage).toContain( + `Altertable CLI v${VERSION} • Query and manage your data platform from the`, + ); + expect(usage).toContain("terminal."); + expect(usage).toContain("Usage\n altertable [flags]"); + expect(usage).toContain("\n Commands\n"); + expect(usage).toContain("\n Commands\n Platform\n"); + expect(usage).toContain("Platform"); + expect(usage).toContain("Ingest"); + expect(usage).toContain("Query"); + const commandsSection = usage.indexOf("\n Commands\n"); + const platformSection = usage.indexOf("\n Platform\n"); + const ingestSection = usage.indexOf("\n Ingest\n"); + const querySection = usage.indexOf("\n Query\n"); + const guidanceSection = usage.indexOf("\n Use altertable --help"); + const flagsSection = usage.indexOf("\n Global flags\n"); + expect(commandsSection).toBeLessThan(platformSection); + expect(platformSection).toBeLessThan(ingestSection); + expect(ingestSection).toBeLessThan(querySection); + expect(querySection).toBeLessThan(guidanceSection); + expect(guidanceSection).toBeLessThan(flagsSection); + expect(usage).toContain("Use altertable --help for more information about a"); + expect(usage).toContain("command."); + expect(usage).toContain("--help, -h"); + expect(usage).toContain("--version, -v"); + }); + + test("wraps descriptions with hanging indentation at the terminal width", async () => { + const originalColumns = process.stdout.columns; + Object.defineProperty(process.stdout, "columns", { value: 80, configurable: true }); + try { + const usage = await renderAltertableUsage(buildMainCommand()); + expect(usage).toContain( + ` catalogs Manage catalogs (databases and connections) in the current\n${" ".repeat(16)}environment.`, + ); + expect(usage).toContain( + ` --agent Agent-friendly preset: structured JSON\n${" ".repeat(41)}output, no pager or terminal styling`, + ); + } finally { + Object.defineProperty(process.stdout, "columns", { + value: originalColumns, + configurable: true, + }); + } + }); + + test("keeps the complete panel inside a very narrow terminal", async () => { + const originalColumns = process.stdout.columns; + Object.defineProperty(process.stdout, "columns", { value: 40, configurable: true }); + try { + const usage = await renderAltertableUsage(buildMainCommand()); + expect(usage.split("\n").every((line) => getVisibleTextWidth(line) <= 40)).toBe(true); + } finally { + Object.defineProperty(process.stdout, "columns", { + value: originalColumns, + configurable: true, + }); + } + }); + + test("renders command usage with shared sections and examples", async () => { + const command = defineAltertableCommand({ + meta: { + name: "demo", + description: "Demo command.", + examples: ["altertable demo --flag value"], + }, + }); + + const usage = await renderAltertableUsage(command); + expect(usage).toContain("Demo command."); + expect(usage).toContain("Usage\n demo"); + expect(usage).toContain("Examples"); + expect(usage).toContain("altertable demo --flag value"); + }); + + test("renders nested command help", async () => { + const main = buildMainCommand(); + const [command, parent] = await resolveSubCommandForUsage(main, ["profile"]); + const usage = await renderAltertableUsage(command, parent); + + expect(usage).toContain("Manage named profiles and stored credentials."); + expect(usage).toContain( + "Usage\n altertable profile [flags] create|list|show|status|use|switch|current|env|direnv|delete", + ); + expect(usage).toContain("Options"); + expect(usage).toContain("Commands"); + expect(usage).toContain("\n Use altertable profile --help"); + expect(usage).toContain("a command."); + }); + + test("highlights command guidance like examples", async () => { + const terminalState = snapshotTerminalState(); + const originalColumns = process.stdout.columns; + Object.defineProperty(process.stdout, "columns", { value: 120, configurable: true }); + forceTerminalColorForTests(); + try { + const main = buildMainCommand(); + const [command, parent] = await resolveSubCommandForUsage(main, ["profile"]); + const usage = await renderAltertableUsage(command, parent); + + expect(usage).toContain( + ` Use ${terminalAccent("altertable profile --help")} for more information about a command.`, + ); + expect(usage).toContain(` ${terminalAccent("altertable profile show")}`); + } finally { + restoreTerminalState(terminalState); + Object.defineProperty(process.stdout, "columns", { + value: originalColumns, + configurable: true, + }); + } + }); + + test("hides advanced subcommands from summary usage", async () => { + const command = defineAltertableCommand({ + meta: { name: "demo", description: "Demo command." }, + subCommands: { + visible: { meta: { name: "visible", description: "Visible command" } }, + advanced: { + meta: { name: "advanced", description: "Advanced command", hidden: true }, + }, + }, + }); + + const usage = await renderAltertableUsage(command); + expect(usage).toContain("visible"); + expect(usage).not.toContain("advanced"); + }); + + test("resolves api command examples for unknown endpoint errors", async () => { + const main = buildMainCommand(); + const [command] = await resolveSubCommandForUsage(main, ["api", "/environments/production"]); + const usage = await renderAltertableUsage(command, main); + + expect(usage).toContain("altertable api /whoami"); + expect(usage).toContain("altertable api GET /environments/production/connections"); + }); +}); + +describe("renderAltertableUsage active context", () => { + let testHome = ""; + let terminalState: TerminalTestState; + + beforeEach(() => { + terminalState = snapshotTerminalState(); + testHome = mkdtempSync(join(tmpdir(), "altertable-usage-test-")); + }); + + afterEach(() => { + restoreTerminalState(terminalState); + runWithCliRuntime(createCliRuntime(getBootstrapCliContext()), () => { + process.env.ALTERTABLE_CONFIG_HOME = testHome; + process.env.ALTERTABLE_SECRET_BACKEND = "file"; + configureClearAll("default"); + }); + rmSync(testHome, { recursive: true, force: true }); + delete process.env.ALTERTABLE_CONFIG_HOME; + delete process.env.ALTERTABLE_SECRET_BACKEND; + }); + + test("omits active context on subcommand help", async () => { + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + process.env.ALTERTABLE_CONFIG_HOME = testHome; + process.env.ALTERTABLE_SECRET_BACKEND = "file"; + + const runtime = createCliRuntime(getBootstrapCliContext()); + await runWithCliRuntime(runtime, async () => { + await configureRunSet({ apiKey: "atm_test", env: "production" }); + setCliRuntime(runtime); + const main = buildMainCommand(); + const [command] = await resolveSubCommandForUsage(main, ["query"]); + const usage = await renderAltertableUsage(command, main); + expect(usage).not.toContain("ENV"); + }); + }); +});