diff --git a/cli/src/cli.ts b/cli/src/cli.ts index eca1aa0..697746a 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 530d17e..27e1b6e 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"); + }); + }); +});