From 1524d52773606497c7ece4b42be827351362aabc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 03:07:41 +0000 Subject: [PATCH 1/2] feat: Generate and package shell completions Add 'seam completion ', which prints a completion script derived from the API definitions bundled with the CLI. The script completes command paths, per-command flags, and flag values for enum and boolean parameters. Write the same scripts to completions/ on prepack and publish them, so a system package can install them without running the CLI. Completions are always generated from the bundled definitions so that they work offline and without logging in. They may lag the definitions served by Seam when config use-remote-api-defs is enabled. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016eYkGybhEJJE3FkaqXdwLv --- .gitignore | 3 + README.md | 36 ++++ package.json | 1 + prepack.ts | 38 +++- src/bin/cli.ts | 24 +++ src/lib/completion/completion-spec.ts | 288 ++++++++++++++++++++++++++ src/lib/completion/completion.test.ts | 168 +++++++++++++++ src/lib/completion/index.ts | 31 +++ src/lib/completion/render-bash.ts | 125 +++++++++++ src/lib/completion/render-fish.ts | 77 +++++++ src/lib/completion/render-zsh.ts | 149 +++++++++++++ 11 files changed, 935 insertions(+), 5 deletions(-) create mode 100644 src/lib/completion/completion-spec.ts create mode 100644 src/lib/completion/completion.test.ts create mode 100644 src/lib/completion/index.ts create mode 100644 src/lib/completion/render-bash.ts create mode 100644 src/lib/completion/render-fish.ts create mode 100644 src/lib/completion/render-zsh.ts diff --git a/.gitignore b/.gitignore index d53437c8..f56945d5 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,9 @@ # Build directories package +# Shell completions generated on prepack +completions + # Environment versions file .versions diff --git a/README.md b/README.md index 2a057850..f50b436e 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,42 @@ seam access-codes create --code "1234" --name "My Code" seam access-codes list --device-id $MY_DOOR ``` +## Shell completion + +The CLI can print a completion script for bash, fish, and zsh that completes +commands, flags, and flag values such as device types. + +Load completions into the current shell with + +```bash +# bash +source <(seam completion bash) + +# zsh +source <(seam completion zsh) +``` + +Install them for every shell with + +```bash +# bash +seam completion bash > /usr/share/bash-completion/completions/seam + +# fish +seam completion fish > ~/.config/fish/completions/seam.fish + +# zsh +seam completion zsh > "${fpath[1]}/_seam" +``` + +The same scripts are packaged under `completions/` in the published package, +so a system package may install them without running the CLI. + +Completions are generated from the API definitions bundled with the CLI. They +do not reflect definitions served by a Seam API server, so they may be out of +date when `seam config use-remote-api-defs` is enabled, and when running the +CLI from a source checkout with `npm run seam`. + ## Development and Testing ### Quickstart diff --git a/package.json b/package.json index 8fc87306..443f46ba 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "index.js.map", "index.d.ts", "bin", + "completions", "lib", "src", "!test", diff --git a/prepack.ts b/prepack.ts index 47108539..84312d3d 100644 --- a/prepack.ts +++ b/prepack.ts @@ -1,30 +1,58 @@ -import { readFile, writeFile } from 'node:fs/promises' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' import { fileURLToPath } from 'node:url' +import type { Blueprint } from '@seamapi/blueprint' import { $ } from 'execa' import getBlueprint from './src/lib/blueprint.js' +import { + completionFileNames, + completionShells, + renderCompletion, +} from './src/lib/completion/index.js' const versionFile = './src/lib/version.ts' const blueprintFile = './src/lib/blueprint.ts' +const completionsDirectory = './completions' const main = async (): Promise => { const version = await injectVersion(resolveFile(versionFile)) // eslint-disable-next-line no-console console.log(`✓ Version ${version} injected into ${versionFile}`) - await injectBlueprint( - resolveFile(blueprintFile), - await getBlueprint({ regenerate: true }), - ) + const blueprint = await getBlueprint({ regenerate: true }) + + await injectBlueprint(resolveFile(blueprintFile), blueprint) // eslint-disable-next-line no-console console.log(`✓ Blueprint injected into ${blueprintFile}`) + await writeCompletions(resolveFile(completionsDirectory), blueprint) + // eslint-disable-next-line no-console + console.log(`✓ Shell completions written to ${completionsDirectory}`) + const { command } = await $`tsc --project tsconfig.prepack.json` // eslint-disable-next-line no-console console.log(`✓ Rebuilt with '${command}'`) } +const writeCompletions = async ( + path: string, + blueprint: Blueprint, +): Promise => { + await mkdir(path, { recursive: true }) + + await Promise.all( + completionShells.map(async (shell) => { + await writeFile( + join(path, completionFileNames[shell]), + renderCompletion(shell, blueprint), + 'utf8', + ) + }), + ) +} + const injectVersion = async (path: string): Promise => { const { version } = await readPackageJson() diff --git a/src/bin/cli.ts b/src/bin/cli.ts index ec58146a..24b2e201 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -7,6 +7,11 @@ import commandLineUsage from 'command-line-usage' import parseArgs, { type ParsedArgs } from 'minimist' import prompts from 'prompts' +import { + completionShells, + isCompletionShell, + renderCompletion, +} from 'lib/completion/index.js' import { getApiBlueprint } from 'lib/get-api-blueprint.js' import { getConfigStore } from 'lib/get-config-store.js' import { getServer } from 'lib/get-server.js' @@ -62,6 +67,10 @@ const sections = [ name: 'seam access-codes list {bold --device-id} $MY_DOOR', summary: 'List you access codes.', }, + { + name: 'seam completion bash', + summary: 'Print a shell completion script for bash, fish, or zsh.', + }, ], }, ] @@ -80,6 +89,21 @@ async function cli(args: ParsedArgs) { process.exit(0) } + if (args._[0] === 'completion') { + const shell = args._[1] + + if (!isCompletionShell(shell)) { + console.log(`Usage: seam completion <${completionShells.join('|')}>`) + process.exit(1) + } + + // Completions always come from the bundled API definitions so that they + // can be generated offline and without logging in. They may lag the + // definitions served by Seam when config use-remote-api-defs is enabled. + console.log(renderCompletion(shell, await getApiBlueprint(false))) + return + } + if ( args._[0] === 'config' && args._[1] === 'set' && diff --git a/src/lib/completion/completion-spec.ts b/src/lib/completion/completion-spec.ts new file mode 100644 index 00000000..9d48280e --- /dev/null +++ b/src/lib/completion/completion-spec.ts @@ -0,0 +1,288 @@ +import type { Blueprint } from '@seamapi/blueprint' + +import { ellipsis } from '../util/ellipsis.js' + +type Endpoint = Blueprint['routes'][number]['endpoints'][number] +type Parameter = Endpoint['request']['parameters'][number] + +export interface CompletionFlag { + /** Long form without the leading `--`, or `null` for short-only flags. */ + long: string | null + /** Short form without the leading `-`, or `null` when there is none. */ + short: string | null + description: string + /** Known values for the flag, used to complete its argument. */ + values: string[] + /** Whether the flag is followed by a value. */ + takesValue: boolean +} + +export interface CompletionCommand { + path: string[] + description: string + flags: CompletionFlag[] +} + +export interface CompletionSubcommand { + name: string + description: string +} + +export interface CompletionGroup { + /** Command path completed by this group, empty for `seam` itself. */ + path: string[] + subcommands: CompletionSubcommand[] +} + +export interface CompletionSpec { + /** Every invocable command, sorted by command path. */ + commands: CompletionCommand[] + /** Every incomplete command path, sorted by command path. */ + groups: CompletionGroup[] + /** Flags accepted regardless of the command. */ + globalFlags: CompletionFlag[] +} + +export const globalFlags: CompletionFlag[] = [ + { + long: 'help', + short: 'h', + description: 'Display the help guide.', + values: [], + takesValue: false, + }, + { + long: 'remote-api-defs', + short: null, + description: 'Use the API definitions served by the Seam API.', + values: [], + takesValue: false, + }, + { + long: 'version', + short: null, + description: 'Print the CLI version.', + values: [], + takesValue: false, + }, + { + long: null, + short: 'y', + description: 'Take the first suggestion instead of prompting.', + values: [], + takesValue: false, + }, +] + +export const flagTokens = (flag: CompletionFlag): string[] => { + const tokens = [] + if (flag.long != null) tokens.push(`--${flag.long}`) + if (flag.short != null) tokens.push(`-${flag.short}`) + return tokens +} + +export const getCompletionSpec = (blueprint: Blueprint): CompletionSpec => { + const commands = sortByPath( + dedupeByPath([ + ...blueprint.routes + .flatMap((route) => route.endpoints) + .map(toCompletionCommand), + ...localCommands, + ]), + ) + + return { commands, groups: toCompletionGroups(commands), globalFlags } +} + +const stringFlag = (long: string, description: string): CompletionFlag => ({ + long, + short: null, + description, + values: [], + takesValue: true, +}) + +/** + * Commands handled by the CLI itself, which have no endpoint in the blueprint. + * + * Keep in sync with the command handling in `src/bin/cli.ts` and the extra + * commands offered by `interactForCommandSelection`. + */ +const localCommands: CompletionCommand[] = [ + { + path: ['completion', 'bash'], + description: 'Print the bash completion script.', + flags: [], + }, + { + path: ['completion', 'fish'], + description: 'Print the fish completion script.', + flags: [], + }, + { + path: ['completion', 'zsh'], + description: 'Print the zsh completion script.', + flags: [], + }, + { + path: ['config', 'reveal-location'], + description: 'Print the path to the CLI configuration file.', + flags: [], + }, + { + path: ['config', 'use-remote-api-defs'], + description: 'Choose whether to use the API definitions served by Seam.', + flags: [], + }, + { + path: ['health', 'get-health'], + description: 'Report the health of the Seam API.', + flags: [], + }, + { + path: ['login'], + description: 'Log in to Seam.', + flags: [ + stringFlag('server', 'Seam API server to log in to.'), + stringFlag('token', 'Personal access token to log in with.'), + stringFlag('workspace-id', 'Workspace to select after logging in.'), + ], + }, + { + path: ['logout'], + description: 'Log out of Seam.', + flags: [], + }, + { + path: ['select', 'server'], + description: 'Select the Seam API server.', + flags: [stringFlag('server', 'Seam API server to select.')], + }, + { + path: ['select', 'workspace'], + description: 'Select the current workspace.', + flags: [], + }, +] + +const toCompletionCommand = (endpoint: Endpoint): CompletionCommand => ({ + path: toCommandPath(endpoint.path), + description: summarize( + endpoint.title === '' ? endpoint.description : endpoint.title, + ), + flags: [...endpoint.request.parameters] + .map(toCompletionFlag) + .sort((a, b) => compare(a.long ?? a.short, b.long ?? b.short)), +}) + +const toCompletionFlag = (parameter: Parameter): CompletionFlag => ({ + long: toFlagName(parameter.name), + short: null, + description: summarize(parameter.description), + values: toFlagValues(parameter), + takesValue: true, +}) + +const toFlagValues = (parameter: Parameter): string[] => { + if (parameter.format === 'enum') { + return parameter.values.map(({ name }) => name).filter(isSafeToken) + } + + if (parameter.format === 'list' && parameter.itemFormat === 'enum') { + return parameter.itemEnumValues.map(({ name }) => name).filter(isSafeToken) + } + + // Nothing marks parameters as boolean-only flags, so minimist reads the next + // argument as the value. + if (parameter.format === 'boolean') return ['true', 'false'] + + return [] +} + +/** + * Whether a word can be written unquoted in a completion script. Command, + * flag, and enum names come from the API definitions, so never emit one that + * could be read as shell syntax. + */ +const isSafeToken = (token: string): boolean => /^[\w.:@/+-]+$/.test(token) + +const toCompletionGroups = ( + commands: CompletionCommand[], +): CompletionGroup[] => { + const groups = new Map>() + + for (const command of commands) { + for (const [depth, name] of command.path.entries()) { + const path = command.path.slice(0, depth) + const key = path.join(' ') + + const subcommands = groups.get(key) ?? new Map() + groups.set(key, subcommands) + + const isCommand = depth === command.path.length - 1 + + // A command and a group may share a name, e.g., a hypothetical + // `seam devices` alongside `seam devices list`. Prefer the command + // description, since it describes what running the name does. + if (isCommand) { + subcommands.set(name, command.description) + continue + } + + if (!subcommands.has(name)) { + subcommands.set(name, `Commands for seam ${[...path, name].join(' ')}`) + } + } + } + + return [...groups] + .map(([key, subcommands]) => ({ + path: key === '' ? [] : key.split(' '), + subcommands: [...subcommands] + .map(([name, description]) => ({ name, description })) + .sort((a, b) => compare(a.name, b.name)), + })) + .sort((a, b) => compare(a.path.join(' '), b.path.join(' '))) +} + +const dedupeByPath = (commands: CompletionCommand[]): CompletionCommand[] => { + const byPath = new Map() + for (const command of commands) { + const key = command.path.join(' ') + if (byPath.has(key)) continue + byPath.set(key, command) + } + return [...byPath.values()] +} + +const sortByPath = (commands: CompletionCommand[]): CompletionCommand[] => + [...commands].sort((a, b) => compare(a.path.join(' '), b.path.join(' '))) + +const compare = (a: string | null, b: string | null): number => + (a ?? '') < (b ?? '') ? -1 : (a ?? '') > (b ?? '') ? 1 : 0 + +const toCommandPath = (path: string): string[] => + path.replace(/^\//, '').split('/').map(toFlagName) + +const toFlagName = (name: string): string => name.replace(/_/g, '-') + +const maxDescriptionLength = 72 + +/** + * Reduce documentation prose to a single short line that is safe to embed in a + * single-quoted shell string. + */ +export const summarize = (description: string): string => { + const text = description + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/[`*]/g, '') + .replace(/\s+/g, ' ') + .trim() + + const [sentence] = text.split(/(?<=\.)\s/) + + return ellipsis( + (sentence ?? text).replace(/['"`$\\:]/g, '').trim(), + maxDescriptionLength, + ) +} diff --git a/src/lib/completion/completion.test.ts b/src/lib/completion/completion.test.ts new file mode 100644 index 00000000..4b6b2665 --- /dev/null +++ b/src/lib/completion/completion.test.ts @@ -0,0 +1,168 @@ +import type { Blueprint } from '@seamapi/blueprint' +import { expect, test } from 'vitest' + +import { getCompletionSpec, summarize } from './completion-spec.js' +import { + completionShells, + isCompletionShell, + renderCompletion, +} from './index.js' + +/** + * A blueprint with just enough shape to render completions from, standing in + * for the API definitions bundled with the CLI. + */ +const blueprint = { + routes: [ + { + endpoints: [ + { + path: '/devices/list', + title: 'List Devices', + description: 'Returns a list of all [devices](https://docs.seam.co).', + request: { + parameters: [ + { + name: 'limit', + description: 'Number of devices to return.', + format: 'number', + }, + { + name: 'device_type', + description: 'Device type: for which you want to list devices.', + format: 'enum', + values: [{ name: 'august_lock' }, { name: 'schlage_lock' }], + }, + { + name: 'is_managed', + description: "Whether the device's account is managed.", + format: 'boolean', + }, + ], + }, + }, + { + path: '/devices/unmanaged/get', + title: '', + description: 'Gets an unmanaged device. Only some fields are set.', + request: { parameters: [] }, + }, + ], + }, + ], +} as unknown as Blueprint + +const spec = getCompletionSpec(blueprint) + +const findCommand = (path: string) => + spec.commands.find((command) => command.path.join(' ') === path) + +const findGroup = (path: string) => + spec.groups.find((group) => group.path.join(' ') === path) + +test('completion spec: derives commands from endpoint paths', () => { + expect(findCommand('devices list')?.description).toBe('List Devices') + expect(findCommand('devices unmanaged get')?.description).toBe( + 'Gets an unmanaged device.', + ) +}) + +test('completion spec: includes commands handled by the CLI itself', () => { + expect(findCommand('login')?.flags.map(({ long }) => long)).toEqual([ + 'server', + 'token', + 'workspace-id', + ]) + expect(findCommand('select workspace')).toBeDefined() + expect(findCommand('completion zsh')).toBeDefined() +}) + +test('completion spec: turns parameters into kebab-case flags', () => { + expect(findCommand('devices list')?.flags.map(({ long }) => long)).toEqual([ + 'device-type', + 'is-managed', + 'limit', + ]) +}) + +test('completion spec: collects values for enum and boolean flags', () => { + const flags = findCommand('devices list')?.flags ?? [] + expect(flags.find(({ long }) => long === 'device-type')?.values).toEqual([ + 'august_lock', + 'schlage_lock', + ]) + expect(flags.find(({ long }) => long === 'is-managed')?.values).toEqual([ + 'true', + 'false', + ]) + expect(flags.find(({ long }) => long === 'limit')?.values).toEqual([]) +}) + +test('completion spec: groups every incomplete command path', () => { + expect(findGroup('')?.subcommands.map(({ name }) => name)).toContain( + 'devices', + ) + expect(findGroup('devices')?.subcommands.map(({ name }) => name)).toEqual([ + 'list', + 'unmanaged', + ]) + expect(findGroup('devices unmanaged')?.subcommands).toEqual([ + { name: 'get', description: 'Gets an unmanaged device.' }, + ]) +}) + +test('summarize: reduces prose to one quotable line', () => { + expect( + summarize('Returns a list of all [devices](https://docs.seam.co).'), + ).toBe('Returns a list of all devices.') + expect(summarize('First sentence. Second sentence.')).toBe('First sentence.') + expect(summarize("Don't use a `colon: here`.")).toBe('Dont use a colon here.') + expect(summarize(`${'a'.repeat(80)}.`)).toHaveLength(72) +}) + +test('isCompletionShell: accepts only supported shells', () => { + expect(completionShells.every(isCompletionShell)).toBe(true) + expect(isCompletionShell('nushell')).toBe(false) + expect(isCompletionShell(undefined)).toBe(false) +}) + +test('bash completion: dispatches on the command path', () => { + const script = renderCompletion('bash', blueprint) + expect(script).toContain('complete -F _seam_completion seam') + expect(script).toContain("'devices') echo 'list unmanaged' ;;") + expect(script).toContain( + "'devices list') echo '--device-type --is-managed --limit' ;;", + ) + expect(script).toContain( + "'devices list --device-type') echo 'august_lock schlage_lock' ;;", + ) +}) + +test('zsh completion: describes every candidate', () => { + const script = renderCompletion('zsh', blueprint) + expect(script.startsWith('#compdef seam\n')).toBe(true) + expect(script).toContain("('devices') _seam_reply+=('list:List Devices'") + expect(script).toContain("'--limit:Number of devices to return.'") + expect(script).toContain( + "('devices list --device-type') _seam_reply+=('august_lock' 'schlage_lock') ;;", + ) +}) + +test('fish completion: guards each candidate with its command path', () => { + const script = renderCompletion('fish', blueprint) + expect(script).toContain('complete -c seam -f') + expect(script).toContain( + `complete -c seam -n '__seam_using "devices"' -a 'list' -d 'List Devices'`, + ) + expect(script).toContain( + `complete -c seam -n '__seam_using "devices list"' -l device-type -r -a 'august_lock schlage_lock' -d 'Device type for which you want to list devices.'`, + ) + expect(script).toContain(`complete -c seam -s h -l help -d`) +}) + +test.each(completionShells)('%s completion: quotes safely', (shell) => { + const script = renderCompletion(shell, blueprint) + // Descriptions are embedded in single-quoted shell strings. + expect(script).not.toContain("device's") + expect(script.endsWith('\n')).toBe(true) +}) diff --git a/src/lib/completion/index.ts b/src/lib/completion/index.ts new file mode 100644 index 00000000..18088584 --- /dev/null +++ b/src/lib/completion/index.ts @@ -0,0 +1,31 @@ +import type { Blueprint } from '@seamapi/blueprint' + +import { type CompletionSpec, getCompletionSpec } from './completion-spec.js' +import { renderBashCompletion } from './render-bash.js' +import { renderFishCompletion } from './render-fish.js' +import { renderZshCompletion } from './render-zsh.js' + +export const completionShells = ['bash', 'fish', 'zsh'] as const + +export type CompletionShell = (typeof completionShells)[number] + +export const isCompletionShell = (shell: unknown): shell is CompletionShell => + completionShells.includes(shell as CompletionShell) + +/** File name to install the completion script for each shell as. */ +export const completionFileNames: Record = { + bash: 'seam.bash', + fish: 'seam.fish', + zsh: 'seam.zsh', +} + +const renderers: Record string> = { + bash: renderBashCompletion, + fish: renderFishCompletion, + zsh: renderZshCompletion, +} + +export const renderCompletion = ( + shell: CompletionShell, + blueprint: Blueprint, +): string => renderers[shell](getCompletionSpec(blueprint)) diff --git a/src/lib/completion/render-bash.ts b/src/lib/completion/render-bash.ts new file mode 100644 index 00000000..354519e3 --- /dev/null +++ b/src/lib/completion/render-bash.ts @@ -0,0 +1,125 @@ +import { + type CompletionFlag, + type CompletionSpec, + flagTokens, +} from './completion-spec.js' + +export const renderBashCompletion = (spec: CompletionSpec): string => { + const globalTokens = spec.globalFlags.flatMap(flagTokens).sort() + const valuelessTokens = spec.globalFlags + .filter(({ takesValue }) => !takesValue) + .flatMap(flagTokens) + .sort() + + return `${[ + header, + `_seam_global_flags='${globalTokens.join(' ')}'`, + `_seam_valueless_flags=' ${valuelessTokens.join(' ')} '`, + renderCase('_seam_subcommands', subcommandBranches(spec)), + renderCase('_seam_flags', flagBranches(spec)), + renderCase('_seam_flag_values', flagValueBranches(spec)), + completionFunction, + 'complete -F _seam_completion seam', + ].join('\n\n')}\n` +} + +const header = `# bash completion for the seam command. +# +# Generated by @seamapi/cli from the API definitions bundled with the CLI. +# Do not edit: regenerate with 'seam completion bash'. +# +# Load it for the current shell with +# +# source <(seam completion bash) +# +# or install it for every shell with +# +# seam completion bash > /usr/share/bash-completion/completions/seam` + +const completionFunction = `_seam_completion() { + local current previous word command subcommands + local -i index + + current="\${COMP_WORDS[COMP_CWORD]}" + previous='' + if (( COMP_CWORD > 0 )); then + previous="\${COMP_WORDS[COMP_CWORD - 1]}" + fi + + # --flag=value splits into three words under the default word breaks. + if [[ "$previous" == '=' ]] && (( COMP_CWORD > 1 )); then + previous="\${COMP_WORDS[COMP_CWORD - 2]}" + fi + + # The command path is the run of words before the first flag. + command='' + for (( index = 1; index < COMP_CWORD; index++ )); do + word="\${COMP_WORDS[index]}" + if [[ "$word" == -* ]]; then + break + fi + command="\${command:+$command }$word" + done + + # Completing the value of a flag that takes one. + if [[ "$previous" == -* && "$_seam_valueless_flags" != *" $previous "* ]]; then + COMPREPLY=( $(compgen -W "$(_seam_flag_values "$command $previous")" -- "$current") ) + return 0 + fi + + if [[ "$current" == -* ]]; then + COMPREPLY=( $(compgen -W "$(_seam_flags "$command") $_seam_global_flags" -- "$current") ) + return 0 + fi + + subcommands="$(_seam_subcommands "$command")" + if [[ -z "$subcommands" ]]; then + COMPREPLY=( $(compgen -W "$(_seam_flags "$command") $_seam_global_flags" -- "$current") ) + return 0 + fi + + COMPREPLY=( $(compgen -W "$subcommands" -- "$current") ) + return 0 +}` + +interface Branch { + pattern: string + words: string[] +} + +const subcommandBranches = (spec: CompletionSpec): Branch[] => + spec.groups.map((group) => ({ + pattern: group.path.join(' '), + words: group.subcommands.map(({ name }) => name), + })) + +const flagBranches = (spec: CompletionSpec): Branch[] => + spec.commands + .filter(({ flags }) => flags.length > 0) + .map((command) => ({ + pattern: command.path.join(' '), + words: command.flags.flatMap(flagTokens), + })) + +const flagValueBranches = (spec: CompletionSpec): Branch[] => + spec.commands.flatMap((command) => + command.flags.filter(hasValues).flatMap((flag) => + flagTokens(flag).map((token) => ({ + pattern: `${command.path.join(' ')} ${token}`, + words: flag.values, + })), + ), + ) + +const hasValues = (flag: CompletionFlag): boolean => flag.values.length > 0 + +const renderCase = (name: string, branches: Branch[]): string => + [ + `${name}() {`, + ` case "$1" in`, + ...branches.map( + ({ pattern, words }) => ` '${pattern}') echo '${words.join(' ')}' ;;`, + ), + ` esac`, + `}`, + ].join('\n') diff --git a/src/lib/completion/render-fish.ts b/src/lib/completion/render-fish.ts new file mode 100644 index 00000000..475d1b3f --- /dev/null +++ b/src/lib/completion/render-fish.ts @@ -0,0 +1,77 @@ +import type { CompletionFlag, CompletionSpec } from './completion-spec.js' + +export const renderFishCompletion = (spec: CompletionSpec): string => + `${[ + header, + helpers, + ['complete -c seam -f', ...subcommandCompletions(spec)].join('\n'), + flagCompletions(spec).join('\n'), + globalFlagCompletions(spec).join('\n'), + ].join('\n\n')}\n` + +const header = `# fish completion for the seam command. +# +# Generated by @seamapi/cli from the API definitions bundled with the CLI. +# Do not edit: regenerate with 'seam completion fish'. +# +# Install it with +# +# seam completion fish > ~/.config/fish/completions/seam.fish` + +const helpers = `function __seam_command --description 'Print the seam command path on the command line' + set -l tokens (commandline -opc) + set -l command + if test (count $tokens) -gt 1 + # The command path is the run of words before the first flag. + for token in $tokens[2..-1] + if string match -q -- '-*' $token + break + end + set -a command $token + end + end + string join ' ' -- $command +end + +function __seam_using --description 'Test whether the command line names the given seam command' + set -l command (__seam_command) + test "$command" = "$argv[1]" +end` + +const subcommandCompletions = (spec: CompletionSpec): string[] => + spec.groups.flatMap((group) => + group.subcommands.map(({ name, description }) => + complete([ + `-n '__seam_using "${group.path.join(' ')}"'`, + `-a '${name}'`, + describe(description), + ]), + ), + ) + +const flagCompletions = (spec: CompletionSpec): string[] => + spec.commands.flatMap((command) => + command.flags.map((flag) => + complete([ + `-n '__seam_using "${command.path.join(' ')}"'`, + ...flagOptions(flag), + ]), + ), + ) + +const globalFlagCompletions = (spec: CompletionSpec): string[] => + spec.globalFlags.map((flag) => complete(flagOptions(flag))) + +const flagOptions = (flag: CompletionFlag): string[] => [ + ...(flag.short == null ? [] : [`-s ${flag.short}`]), + ...(flag.long == null ? [] : [`-l ${flag.long}`]), + ...(flag.takesValue ? ['-r'] : []), + ...(flag.values.length === 0 ? [] : [`-a '${flag.values.join(' ')}'`]), + describe(flag.description), +] + +const describe = (description: string): string => + description === '' ? '' : `-d '${description}'` + +const complete = (options: string[]): string => + ['complete -c seam', ...options.filter((option) => option !== '')].join(' ') diff --git a/src/lib/completion/render-zsh.ts b/src/lib/completion/render-zsh.ts new file mode 100644 index 00000000..9511d38b --- /dev/null +++ b/src/lib/completion/render-zsh.ts @@ -0,0 +1,149 @@ +import { + type CompletionFlag, + type CompletionSpec, + flagTokens, +} from './completion-spec.js' + +export const renderZshCompletion = (spec: CompletionSpec): string => { + const valuelessTokens = spec.globalFlags + .filter(({ takesValue }) => !takesValue) + .flatMap(flagTokens) + .sort() + + return `${[ + header, + renderCase('_seam_subcommands', subcommandBranches(spec)), + renderCase('_seam_flags', flagBranches(spec)), + renderCase('_seam_flag_values', flagValueBranches(spec)), + `_seam_global_flags() {\n _seam_reply+=(${describeFlags(spec.globalFlags)})\n}`, + completionFunction(valuelessTokens), + dispatch, + ].join('\n\n')}\n` +} + +const header = `#compdef seam + +# zsh completion for the seam command. +# +# Generated by @seamapi/cli from the API definitions bundled with the CLI. +# Do not edit: regenerate with 'seam completion zsh'. +# +# Load it for the current shell with +# +# source <(seam completion zsh) +# +# or install it for every shell with +# +# seam completion zsh > "\${fpath[1]}/_seam"` + +const completionFunction = (valuelessTokens: string[]): string => + `_seam() { + local -a _seam_reply + local -a valueless + local command previous word + local -i index + + valueless=(${valuelessTokens.join(' ')}) + + # The command path is the run of words before the first flag. + command='' + for (( index = 2; index < CURRENT; index++ )); do + word="\${words[index]}" + if [[ "$word" == -* ]]; then + break + fi + command="\${command:+$command }$word" + done + + previous='' + if (( CURRENT > 1 )); then + previous="\${words[CURRENT - 1]}" + fi + + # Completing the value of a flag that takes one. + if [[ "$previous" == -* ]] && (( \${valueless[(Ie)$previous]} == 0 )); then + _seam_flag_values "$command $previous" + if (( \${#_seam_reply} )); then + _describe -t values 'value' _seam_reply + fi + return + fi + + if [[ "\${words[CURRENT]}" == -* ]]; then + _seam_flags "$command" + _seam_global_flags + _describe -t options 'option' _seam_reply + return + fi + + _seam_subcommands "$command" + if (( \${#_seam_reply} )); then + _describe -t commands 'command' _seam_reply + return + fi + + _seam_flags "$command" + _seam_global_flags + _describe -t options 'option' _seam_reply +}` + +const dispatch = `if [ "\${funcstack[1]}" = '_seam' ]; then + _seam "$@" +else + compdef _seam seam +fi` + +interface Branch { + pattern: string + entries: string[] +} + +const subcommandBranches = (spec: CompletionSpec): Branch[] => + spec.groups.map((group) => ({ + pattern: group.path.join(' '), + entries: group.subcommands.map(({ name, description }) => + describe(name, description), + ), + })) + +const flagBranches = (spec: CompletionSpec): Branch[] => + spec.commands + .filter(({ flags }) => flags.length > 0) + .map((command) => ({ + pattern: command.path.join(' '), + entries: [describeFlags(command.flags)], + })) + +const flagValueBranches = (spec: CompletionSpec): Branch[] => + spec.commands.flatMap((command) => + command.flags + .filter(({ values }) => values.length > 0) + .flatMap((flag) => + flagTokens(flag).map((token) => ({ + pattern: `${command.path.join(' ')} ${token}`, + entries: flag.values.map((value) => `'${value}'`), + })), + ), + ) + +const describeFlags = (flags: CompletionFlag[]): string => + flags + .flatMap((flag) => + flagTokens(flag).map((token) => describe(token, flag.description)), + ) + .join(' ') + +const describe = (value: string, description: string): string => + description === '' ? `'${value}'` : `'${value}:${description}'` + +const renderCase = (name: string, branches: Branch[]): string => + [ + `${name}() {`, + ` case "$1" in`, + ...branches.map( + ({ pattern, entries }) => + ` ('${pattern}') _seam_reply+=(${entries.join(' ')}) ;;`, + ), + ` esac`, + `}`, + ].join('\n') From 74a41c1752f79cb9a5db85b10c494cccde742a77 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 03:41:48 +0000 Subject: [PATCH 2/2] feat: Make --help describe the command it is passed with Help was one static page listing four examples and a single option, no matter which command it followed. Render it from the same command spec that drives completions instead, so it answers the command it is passed with: seam --help every top level command seam devices --help the commands under seam devices seam devices list --help the options seam devices list accepts Command help documents every parameter of the endpoint, marking the required ones and naming the values of enum and boolean parameters. An unrecognized command path reports itself rather than printing the root guide. Move the command spec out of lib/completion, since completions are now one of its two consumers, and keep shell quoting in the completion renderers so help can carry the full text of a description. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016eYkGybhEJJE3FkaqXdwLv --- README.md | 18 ++ src/bin/cli.ts | 81 +++----- src/lib/command-spec.test.ts | 111 ++++++++++ .../completion-spec.ts => command-spec.ts} | 195 +++++++++++------- src/lib/completion/completion.test.ts | 135 ++---------- src/lib/completion/describe.ts | 21 ++ src/lib/completion/index.ts | 6 +- src/lib/completion/render-bash.ts | 16 +- src/lib/completion/render-fish.ts | 19 +- src/lib/completion/render-zsh.ts | 23 ++- src/lib/render-help.test.ts | 73 +++++++ src/lib/render-help.ts | 149 +++++++++++++ test/fixtures/blueprint.ts | 58 ++++++ 13 files changed, 629 insertions(+), 276 deletions(-) create mode 100644 src/lib/command-spec.test.ts rename src/lib/{completion/completion-spec.ts => command-spec.ts} (51%) create mode 100644 src/lib/completion/describe.ts create mode 100644 src/lib/render-help.test.ts create mode 100644 src/lib/render-help.ts create mode 100644 test/fixtures/blueprint.ts diff --git a/README.md b/README.md index f50b436e..938b917c 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,24 @@ seam access-codes create --code "1234" --name "My Code" seam access-codes list --device-id $MY_DOOR ``` +## Help + +Pass `--help` to any command to see what it accepts. Without a command, it +lists every top level command; with an incomplete command, it lists the +subcommands under it; with a full command, it documents that command's +options, marking the required ones. + +```bash +# Every top level command +seam --help + +# The commands under seam devices +seam devices --help + +# The options accepted by seam devices list +seam devices list --help +``` + ## Shell completion The CLI can print a completion script for bash, fish, and zsh that completes diff --git a/src/bin/cli.ts b/src/bin/cli.ts index 24b2e201..0b45b5e4 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -3,10 +3,10 @@ import { randomBytes } from 'node:crypto' import { isDeepStrictEqual as isEqual } from 'node:util' import chalk from 'chalk' -import commandLineUsage from 'command-line-usage' import parseArgs, { type ParsedArgs } from 'minimist' import prompts from 'prompts' +import { getCommandSpec } from 'lib/command-spec.js' import { completionShells, isCompletionShell, @@ -22,65 +22,37 @@ import { interactForLogin } from 'lib/interact-for-login.js' import { interactForServerSelection } from 'lib/interact-for-server-selection.js' import { interactForUseRemoteApiDefs } from 'lib/interact-for-use-remote-api-defs.js' import { interactForWorkspaceId } from 'lib/interact-for-workspace-id.js' +import { renderHelp } from 'lib/render-help.js' import type { ContextHelpers } from 'lib/types.js' import { RequestSeamApi } from 'lib/util/request-seam-api.js' import { validateToken } from 'lib/validate-token.js' import seamapiCliVersion from 'lib/version.js' -const sections = [ - { - header: 'Seam CLI', - content: - 'Every seam command is interactive and will prompt you for any missing required properties with helpful suggestions. To avoid automatic behavior, pass -y ', - }, - { - header: 'Options', - optionList: [ - { - name: 'help', - description: 'Display this help guide.', - alias: 'h', - type: Boolean, - }, - ], - }, - { - header: 'Command List Examples', - content: [ - { name: 'seam', summary: 'Interactively select commands to execute.' }, - { name: 'seam login', summary: 'Login to Seam.' }, - { name: 'seam select workspace', summary: 'Select your workspace.' }, - { - name: 'seam connect-webviews create', - summary: 'Create a connect webview to connect devices.', - }, - { name: 'seam devices list', summary: 'List devices in your workspace.' }, - { - name: 'seam locks unlock-door {bold --device-id} $MY_DOOR', - summary: 'Unlock a lock.', - }, - { - name: "seam access-codes create {bold --code} '1234' {bold --name} 'My Code'", - summary: 'Create an access code.', - }, - { - name: 'seam access-codes list {bold --device-id} $MY_DOOR', - summary: 'List you access codes.', - }, - { - name: 'seam completion bash', - summary: 'Print a shell completion script for bash, fish, or zsh.', - }, - ], - }, -] - async function cli(args: ParsedArgs) { const config = getConfigStore() - if (args['help'] || args['h']) { - const usage = commandLineUsage(sections) - console.log(usage) + const helpFlag = args['help'] ?? args['h'] + if (helpFlag != null) { + // Help comes from the bundled API definitions so that it works offline and + // without logging in. + const spec = getCommandSpec(await getApiBlueprint(false)) + + // minimist reads the word after --help as its value, so 'seam --help + // devices' asks about devices just as 'seam devices --help' does. + const commandPath = [ + ...args._, + ...(typeof helpFlag === 'string' ? [helpFlag] : []), + ].map(toCommandWord) + + const help = renderHelp(commandPath, spec) + + if (help == null) { + console.log(chalk.red(`Unknown command: seam ${commandPath.join(' ')}`)) + console.log(`Run 'seam --help' to see the available commands.`) + process.exit(1) + } + + console.log(help) return } @@ -129,7 +101,7 @@ async function cli(args: ParsedArgs) { process.exit(1) } - args._ = args._.map((arg) => arg.toLowerCase().replace(/_/g, '-')) + args._ = args._.map(toCommandWord) for (const k in args) { args[k.toLowerCase().replace(/-/g, '_')] = args[k] } @@ -264,6 +236,9 @@ async function cli(args: ParsedArgs) { } } +const toCommandWord = (arg: string): string => + arg.toLowerCase().replace(/_/g, '-') + const handleConnectWebviewResponse = async (connect_webview: any) => { const url = connect_webview.url diff --git a/src/lib/command-spec.test.ts b/src/lib/command-spec.test.ts new file mode 100644 index 00000000..55b66c5a --- /dev/null +++ b/src/lib/command-spec.test.ts @@ -0,0 +1,111 @@ +import { expect, test } from 'vitest' + +import { testBlueprint } from '../../test/fixtures/blueprint.js' +import { + findCommand, + findGroup, + firstSentence, + getCommandSpec, + toPlainText, +} from './command-spec.js' + +const spec = getCommandSpec(testBlueprint) + +test('command spec: derives commands from endpoint paths', () => { + expect(findCommand(spec, ['devices', 'list'])?.title).toBe('List Devices') + expect(findCommand(spec, ['devices', 'list'])?.description).toBe( + 'Returns a list of all devices. Results are paginated.', + ) +}) + +test('command spec: falls back to the first sentence for an untitled endpoint', () => { + expect(findCommand(spec, ['devices', 'unmanaged', 'get'])?.title).toBe( + 'Gets an unmanaged device.', + ) +}) + +test('command spec: includes commands handled by the CLI itself', () => { + expect(findCommand(spec, ['login'])?.flags.map(({ long }) => long)).toEqual([ + 'server', + 'token', + 'workspace-id', + ]) + expect(findCommand(spec, ['select', 'workspace'])).toBeDefined() + expect(findCommand(spec, ['completion', 'zsh'])).toBeDefined() +}) + +test('command spec: turns parameters into kebab-case flags', () => { + expect( + findCommand(spec, ['devices', 'list'])?.flags.map(({ long }) => long), + ).toEqual(['device-type', 'is-managed', 'limit']) +}) + +test('command spec: carries whether a flag is required', () => { + expect( + findCommand(spec, ['devices', 'unmanaged', 'get'])?.flags, + ).toMatchObject([{ long: 'device-id', isRequired: true }]) + expect( + findCommand(spec, ['devices', 'list'])?.flags.map( + ({ isRequired }) => isRequired, + ), + ).toEqual([false, false, false]) +}) + +test('command spec: collects values for enum and boolean flags', () => { + const flags = findCommand(spec, ['devices', 'list'])?.flags ?? [] + expect(flags.find(({ long }) => long === 'device-type')?.values).toEqual([ + 'august_lock', + 'schlage_lock', + ]) + expect(flags.find(({ long }) => long === 'is-managed')?.values).toEqual([ + 'true', + 'false', + ]) + expect(flags.find(({ long }) => long === 'limit')?.values).toEqual([]) +}) + +test('command spec: groups every incomplete command path', () => { + expect(findGroup(spec, [])?.subcommands.map(({ name }) => name)).toContain( + 'devices', + ) + expect( + findGroup(spec, ['devices'])?.subcommands.map(({ name }) => name), + ).toEqual(['list', 'unmanaged']) + expect(findGroup(spec, ['devices', 'unmanaged'])?.subcommands).toEqual([ + { name: 'get', description: 'Gets an unmanaged device.' }, + ]) +}) + +test('command spec: names the commands a group holds', () => { + const subcommands = findGroup(spec, [])?.subcommands ?? [] + expect(subcommands.find(({ name }) => name === 'devices')?.description).toBe( + 'list, unmanaged', + ) + expect( + subcommands.find(({ name }) => name === 'completion')?.description, + ).toBe('bash, fish, zsh') +}) + +test('command spec: a command path is either a command or a group', () => { + expect(findGroup(spec, ['devices', 'list'])).toBeUndefined() + expect(findCommand(spec, ['devices'])).toBeUndefined() + expect(findCommand(spec, ['nope'])).toBeUndefined() + expect(findGroup(spec, ['nope'])).toBeUndefined() +}) + +test('toPlainText: reduces markdown to one line', () => { + expect(toPlainText('Returns all [devices](https://docs.seam.co).')).toBe( + 'Returns all devices.', + ) + expect(toPlainText('Uses `code`\nand **bold**.')).toBe('Uses code and bold.') + expect(toPlainText("Keeps the device's colon: intact.")).toBe( + "Keeps the device's colon: intact.", + ) +}) + +test('firstSentence: stops at the first sentence break', () => { + expect(firstSentence('First sentence. Second sentence.')).toBe( + 'First sentence.', + ) + expect(firstSentence('No break here')).toBe('No break here') +}) diff --git a/src/lib/completion/completion-spec.ts b/src/lib/command-spec.ts similarity index 51% rename from src/lib/completion/completion-spec.ts rename to src/lib/command-spec.ts index 9d48280e..b5a8d9c6 100644 --- a/src/lib/completion/completion-spec.ts +++ b/src/lib/command-spec.ts @@ -1,55 +1,58 @@ import type { Blueprint } from '@seamapi/blueprint' -import { ellipsis } from '../util/ellipsis.js' - type Endpoint = Blueprint['routes'][number]['endpoints'][number] type Parameter = Endpoint['request']['parameters'][number] -export interface CompletionFlag { +export interface CommandFlag { /** Long form without the leading `--`, or `null` for short-only flags. */ long: string | null /** Short form without the leading `-`, or `null` when there is none. */ short: string | null description: string - /** Known values for the flag, used to complete its argument. */ + /** Known values for the flag, used to complete and document its argument. */ values: string[] /** Whether the flag is followed by a value. */ takesValue: boolean + isRequired: boolean } -export interface CompletionCommand { +export interface CommandDefinition { path: string[] + /** One line naming what the command does. */ + title: string + /** Longer prose about the command, empty when there is none to add. */ description: string - flags: CompletionFlag[] + flags: CommandFlag[] } -export interface CompletionSubcommand { +export interface Subcommand { name: string description: string } -export interface CompletionGroup { +export interface CommandGroup { /** Command path completed by this group, empty for `seam` itself. */ path: string[] - subcommands: CompletionSubcommand[] + subcommands: Subcommand[] } -export interface CompletionSpec { +export interface CommandSpec { /** Every invocable command, sorted by command path. */ - commands: CompletionCommand[] + commands: CommandDefinition[] /** Every incomplete command path, sorted by command path. */ - groups: CompletionGroup[] + groups: CommandGroup[] /** Flags accepted regardless of the command. */ - globalFlags: CompletionFlag[] + globalFlags: CommandFlag[] } -export const globalFlags: CompletionFlag[] = [ +export const globalFlags: CommandFlag[] = [ { long: 'help', short: 'h', - description: 'Display the help guide.', + description: 'Display this help guide.', values: [], takesValue: false, + isRequired: false, }, { long: 'remote-api-defs', @@ -57,6 +60,7 @@ export const globalFlags: CompletionFlag[] = [ description: 'Use the API definitions served by the Seam API.', values: [], takesValue: false, + isRequired: false, }, { long: 'version', @@ -64,6 +68,7 @@ export const globalFlags: CompletionFlag[] = [ description: 'Print the CLI version.', values: [], takesValue: false, + isRequired: false, }, { long: null, @@ -71,35 +76,52 @@ export const globalFlags: CompletionFlag[] = [ description: 'Take the first suggestion instead of prompting.', values: [], takesValue: false, + isRequired: false, }, ] -export const flagTokens = (flag: CompletionFlag): string[] => { +export const flagTokens = (flag: CommandFlag): string[] => { const tokens = [] if (flag.long != null) tokens.push(`--${flag.long}`) if (flag.short != null) tokens.push(`-${flag.short}`) return tokens } -export const getCompletionSpec = (blueprint: Blueprint): CompletionSpec => { +export const getCommandSpec = (blueprint: Blueprint): CommandSpec => { const commands = sortByPath( dedupeByPath([ ...blueprint.routes .flatMap((route) => route.endpoints) - .map(toCompletionCommand), + .map(toCommandDefinition), ...localCommands, ]), ) - return { commands, groups: toCompletionGroups(commands), globalFlags } + return { commands, groups: toCommandGroups(commands), globalFlags } } -const stringFlag = (long: string, description: string): CompletionFlag => ({ +export const findCommand = ( + spec: CommandSpec, + path: string[], +): CommandDefinition | undefined => + spec.commands.find((command) => isSamePath(command.path, path)) + +export const findGroup = ( + spec: CommandSpec, + path: string[], +): CommandGroup | undefined => + spec.groups.find((group) => isSamePath(group.path, path)) + +const isSamePath = (a: string[], b: string[]): boolean => + a.length === b.length && a.every((word, index) => word === b[index]) + +const stringFlag = (long: string, description: string): CommandFlag => ({ long, short: null, description, values: [], takesValue: true, + isRequired: false, }) /** @@ -108,40 +130,48 @@ const stringFlag = (long: string, description: string): CompletionFlag => ({ * Keep in sync with the command handling in `src/bin/cli.ts` and the extra * commands offered by `interactForCommandSelection`. */ -const localCommands: CompletionCommand[] = [ +const localCommands: CommandDefinition[] = [ { path: ['completion', 'bash'], - description: 'Print the bash completion script.', + title: 'Print the bash completion script.', + description: '', flags: [], }, { path: ['completion', 'fish'], - description: 'Print the fish completion script.', + title: 'Print the fish completion script.', + description: '', flags: [], }, { path: ['completion', 'zsh'], - description: 'Print the zsh completion script.', + title: 'Print the zsh completion script.', + description: '', flags: [], }, { path: ['config', 'reveal-location'], - description: 'Print the path to the CLI configuration file.', + title: 'Print the path to the CLI configuration file.', + description: '', flags: [], }, { path: ['config', 'use-remote-api-defs'], - description: 'Choose whether to use the API definitions served by Seam.', + title: 'Choose whether to use the API definitions served by Seam.', + description: '', flags: [], }, { path: ['health', 'get-health'], - description: 'Report the health of the Seam API.', + title: 'Report the health of the Seam API.', + description: '', flags: [], }, { path: ['login'], - description: 'Log in to Seam.', + title: 'Log in to Seam.', + description: + 'Prompts for a personal access token unless one is passed with --token.', flags: [ stringFlag('server', 'Seam API server to log in to.'), stringFlag('token', 'Personal access token to log in with.'), @@ -150,37 +180,47 @@ const localCommands: CompletionCommand[] = [ }, { path: ['logout'], - description: 'Log out of Seam.', + title: 'Log out of Seam.', + description: '', flags: [], }, { path: ['select', 'server'], - description: 'Select the Seam API server.', + title: 'Select the Seam API server.', + description: '', flags: [stringFlag('server', 'Seam API server to select.')], }, { path: ['select', 'workspace'], - description: 'Select the current workspace.', + title: 'Select the current workspace.', + description: '', flags: [], }, ] -const toCompletionCommand = (endpoint: Endpoint): CompletionCommand => ({ - path: toCommandPath(endpoint.path), - description: summarize( - endpoint.title === '' ? endpoint.description : endpoint.title, - ), - flags: [...endpoint.request.parameters] - .map(toCompletionFlag) - .sort((a, b) => compare(a.long ?? a.short, b.long ?? b.short)), -}) +const toCommandDefinition = (endpoint: Endpoint): CommandDefinition => { + const description = toPlainText(endpoint.description) + + return { + path: toCommandPath(endpoint.path), + title: + endpoint.title === '' + ? firstSentence(description) + : toPlainText(endpoint.title), + description, + flags: [...endpoint.request.parameters] + .map(toCommandFlag) + .sort((a, b) => compare(a.long ?? a.short, b.long ?? b.short)), + } +} -const toCompletionFlag = (parameter: Parameter): CompletionFlag => ({ +const toCommandFlag = (parameter: Parameter): CommandFlag => ({ long: toFlagName(parameter.name), short: null, - description: summarize(parameter.description), + description: toPlainText(parameter.description), values: toFlagValues(parameter), takesValue: true, + isRequired: parameter.isRequired, }) const toFlagValues = (parameter: Parameter): string[] => { @@ -206,47 +246,58 @@ const toFlagValues = (parameter: Parameter): string[] => { */ const isSafeToken = (token: string): boolean => /^[\w.:@/+-]+$/.test(token) -const toCompletionGroups = ( - commands: CompletionCommand[], -): CompletionGroup[] => { - const groups = new Map>() +interface GroupEntry { + isCommand: boolean + description: string +} + +const toCommandGroups = (commands: CommandDefinition[]): CommandGroup[] => { + const groups = new Map>() for (const command of commands) { for (const [depth, name] of command.path.entries()) { - const path = command.path.slice(0, depth) - const key = path.join(' ') - - const subcommands = groups.get(key) ?? new Map() - groups.set(key, subcommands) + const key = command.path.slice(0, depth).join(' ') - const isCommand = depth === command.path.length - 1 + const entries = groups.get(key) ?? new Map() + groups.set(key, entries) // A command and a group may share a name, e.g., a hypothetical // `seam devices` alongside `seam devices list`. Prefer the command - // description, since it describes what running the name does. - if (isCommand) { - subcommands.set(name, command.description) + // title, since it describes what running the name does. + if (depth === command.path.length - 1) { + entries.set(name, { isCommand: true, description: command.title }) continue } - if (!subcommands.has(name)) { - subcommands.set(name, `Commands for seam ${[...path, name].join(' ')}`) + if (!entries.has(name)) { + entries.set(name, { isCommand: false, description: '' }) } } } + // Groups have no description of their own in the API definitions, so name + // the commands they hold instead. Leave the list whole: help wraps it, and + // completion shortens it to fit a menu column. + const summarizeGroup = (key: string): string => + [...(groups.get(key)?.keys() ?? [])].join(', ') + return [...groups] - .map(([key, subcommands]) => ({ + .map(([key, entries]) => ({ path: key === '' ? [] : key.split(' '), - subcommands: [...subcommands] - .map(([name, description]) => ({ name, description })) + subcommands: [...entries] + .map(([name, entry]) => ({ + name, + description: entry.isCommand + ? entry.description + : summarizeGroup(key === '' ? name : `${key} ${name}`), + })) .sort((a, b) => compare(a.name, b.name)), })) .sort((a, b) => compare(a.path.join(' '), b.path.join(' '))) } -const dedupeByPath = (commands: CompletionCommand[]): CompletionCommand[] => { - const byPath = new Map() +const dedupeByPath = (commands: CommandDefinition[]): CommandDefinition[] => { + const byPath = new Map() for (const command of commands) { const key = command.path.join(' ') if (byPath.has(key)) continue @@ -255,7 +306,7 @@ const dedupeByPath = (commands: CompletionCommand[]): CompletionCommand[] => { return [...byPath.values()] } -const sortByPath = (commands: CompletionCommand[]): CompletionCommand[] => +const sortByPath = (commands: CommandDefinition[]): CommandDefinition[] => [...commands].sort((a, b) => compare(a.path.join(' '), b.path.join(' '))) const compare = (a: string | null, b: string | null): number => @@ -266,23 +317,15 @@ const toCommandPath = (path: string): string[] => const toFlagName = (name: string): string => name.replace(/_/g, '-') -const maxDescriptionLength = 72 - -/** - * Reduce documentation prose to a single short line that is safe to embed in a - * single-quoted shell string. - */ -export const summarize = (description: string): string => { - const text = description +/** Reduce documentation markdown to a single line of prose. */ +export const toPlainText = (markdown: string): string => + markdown .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') .replace(/[`*]/g, '') .replace(/\s+/g, ' ') .trim() +export const firstSentence = (text: string): string => { const [sentence] = text.split(/(?<=\.)\s/) - - return ellipsis( - (sentence ?? text).replace(/['"`$\\:]/g, '').trim(), - maxDescriptionLength, - ) + return sentence ?? text } diff --git a/src/lib/completion/completion.test.ts b/src/lib/completion/completion.test.ts index 4b6b2665..f0630a88 100644 --- a/src/lib/completion/completion.test.ts +++ b/src/lib/completion/completion.test.ts @@ -1,133 +1,32 @@ -import type { Blueprint } from '@seamapi/blueprint' import { expect, test } from 'vitest' -import { getCompletionSpec, summarize } from './completion-spec.js' +import { testBlueprint } from '../../../test/fixtures/blueprint.js' +import { describeForShell } from './describe.js' import { completionShells, isCompletionShell, renderCompletion, } from './index.js' -/** - * A blueprint with just enough shape to render completions from, standing in - * for the API definitions bundled with the CLI. - */ -const blueprint = { - routes: [ - { - endpoints: [ - { - path: '/devices/list', - title: 'List Devices', - description: 'Returns a list of all [devices](https://docs.seam.co).', - request: { - parameters: [ - { - name: 'limit', - description: 'Number of devices to return.', - format: 'number', - }, - { - name: 'device_type', - description: 'Device type: for which you want to list devices.', - format: 'enum', - values: [{ name: 'august_lock' }, { name: 'schlage_lock' }], - }, - { - name: 'is_managed', - description: "Whether the device's account is managed.", - format: 'boolean', - }, - ], - }, - }, - { - path: '/devices/unmanaged/get', - title: '', - description: 'Gets an unmanaged device. Only some fields are set.', - request: { parameters: [] }, - }, - ], - }, - ], -} as unknown as Blueprint - -const spec = getCompletionSpec(blueprint) - -const findCommand = (path: string) => - spec.commands.find((command) => command.path.join(' ') === path) - -const findGroup = (path: string) => - spec.groups.find((group) => group.path.join(' ') === path) - -test('completion spec: derives commands from endpoint paths', () => { - expect(findCommand('devices list')?.description).toBe('List Devices') - expect(findCommand('devices unmanaged get')?.description).toBe( - 'Gets an unmanaged device.', - ) -}) - -test('completion spec: includes commands handled by the CLI itself', () => { - expect(findCommand('login')?.flags.map(({ long }) => long)).toEqual([ - 'server', - 'token', - 'workspace-id', - ]) - expect(findCommand('select workspace')).toBeDefined() - expect(findCommand('completion zsh')).toBeDefined() -}) - -test('completion spec: turns parameters into kebab-case flags', () => { - expect(findCommand('devices list')?.flags.map(({ long }) => long)).toEqual([ - 'device-type', - 'is-managed', - 'limit', - ]) -}) - -test('completion spec: collects values for enum and boolean flags', () => { - const flags = findCommand('devices list')?.flags ?? [] - expect(flags.find(({ long }) => long === 'device-type')?.values).toEqual([ - 'august_lock', - 'schlage_lock', - ]) - expect(flags.find(({ long }) => long === 'is-managed')?.values).toEqual([ - 'true', - 'false', - ]) - expect(flags.find(({ long }) => long === 'limit')?.values).toEqual([]) -}) - -test('completion spec: groups every incomplete command path', () => { - expect(findGroup('')?.subcommands.map(({ name }) => name)).toContain( - 'devices', - ) - expect(findGroup('devices')?.subcommands.map(({ name }) => name)).toEqual([ - 'list', - 'unmanaged', - ]) - expect(findGroup('devices unmanaged')?.subcommands).toEqual([ - { name: 'get', description: 'Gets an unmanaged device.' }, - ]) -}) - -test('summarize: reduces prose to one quotable line', () => { - expect( - summarize('Returns a list of all [devices](https://docs.seam.co).'), - ).toBe('Returns a list of all devices.') - expect(summarize('First sentence. Second sentence.')).toBe('First sentence.') - expect(summarize("Don't use a `colon: here`.")).toBe('Dont use a colon here.') - expect(summarize(`${'a'.repeat(80)}.`)).toHaveLength(72) -}) - test('isCompletionShell: accepts only supported shells', () => { expect(completionShells.every(isCompletionShell)).toBe(true) expect(isCompletionShell('nushell')).toBe(false) expect(isCompletionShell(undefined)).toBe(false) }) +test('describeForShell: drops characters that would end a quoted string', () => { + expect(describeForShell("Whether the device's colon: is set.")).toBe( + 'Whether the devices colon is set.', + ) + expect(describeForShell('First sentence. Second sentence.')).toBe( + 'First sentence.', + ) + expect(describeForShell(`${'a'.repeat(80)}.`)).toHaveLength(72) + expect(describeForShell('')).toBe('') +}) + test('bash completion: dispatches on the command path', () => { - const script = renderCompletion('bash', blueprint) + const script = renderCompletion('bash', testBlueprint) expect(script).toContain('complete -F _seam_completion seam') expect(script).toContain("'devices') echo 'list unmanaged' ;;") expect(script).toContain( @@ -139,7 +38,7 @@ test('bash completion: dispatches on the command path', () => { }) test('zsh completion: describes every candidate', () => { - const script = renderCompletion('zsh', blueprint) + const script = renderCompletion('zsh', testBlueprint) expect(script.startsWith('#compdef seam\n')).toBe(true) expect(script).toContain("('devices') _seam_reply+=('list:List Devices'") expect(script).toContain("'--limit:Number of devices to return.'") @@ -149,7 +48,7 @@ test('zsh completion: describes every candidate', () => { }) test('fish completion: guards each candidate with its command path', () => { - const script = renderCompletion('fish', blueprint) + const script = renderCompletion('fish', testBlueprint) expect(script).toContain('complete -c seam -f') expect(script).toContain( `complete -c seam -n '__seam_using "devices"' -a 'list' -d 'List Devices'`, @@ -161,7 +60,7 @@ test('fish completion: guards each candidate with its command path', () => { }) test.each(completionShells)('%s completion: quotes safely', (shell) => { - const script = renderCompletion(shell, blueprint) + const script = renderCompletion(shell, testBlueprint) // Descriptions are embedded in single-quoted shell strings. expect(script).not.toContain("device's") expect(script.endsWith('\n')).toBe(true) diff --git a/src/lib/completion/describe.ts b/src/lib/completion/describe.ts new file mode 100644 index 00000000..5eb038cb --- /dev/null +++ b/src/lib/completion/describe.ts @@ -0,0 +1,21 @@ +import { firstSentence } from '../command-spec.js' +import { ellipsis } from '../util/ellipsis.js' + +const maxDescriptionLength = 72 + +/** + * Reduce a description to one short line that is safe to embed in a + * single-quoted shell string. + * + * Completion menus give a description a single narrow column, and the shells + * offer no way to escape a quote inside the generated scripts, so drop any + * character that would end the string early. A colon goes too, since zsh reads + * it as the separator in a `_describe` entry. + */ +export const describeForShell = (description: string): string => + ellipsis( + firstSentence(description) + .replace(/['"`$\\:]/g, '') + .trim(), + maxDescriptionLength, + ) diff --git a/src/lib/completion/index.ts b/src/lib/completion/index.ts index 18088584..5f2b78f4 100644 --- a/src/lib/completion/index.ts +++ b/src/lib/completion/index.ts @@ -1,6 +1,6 @@ import type { Blueprint } from '@seamapi/blueprint' -import { type CompletionSpec, getCompletionSpec } from './completion-spec.js' +import { type CommandSpec, getCommandSpec } from '../command-spec.js' import { renderBashCompletion } from './render-bash.js' import { renderFishCompletion } from './render-fish.js' import { renderZshCompletion } from './render-zsh.js' @@ -19,7 +19,7 @@ export const completionFileNames: Record = { zsh: 'seam.zsh', } -const renderers: Record string> = { +const renderers: Record string> = { bash: renderBashCompletion, fish: renderFishCompletion, zsh: renderZshCompletion, @@ -28,4 +28,4 @@ const renderers: Record string> = { export const renderCompletion = ( shell: CompletionShell, blueprint: Blueprint, -): string => renderers[shell](getCompletionSpec(blueprint)) +): string => renderers[shell](getCommandSpec(blueprint)) diff --git a/src/lib/completion/render-bash.ts b/src/lib/completion/render-bash.ts index 354519e3..6f8ba1f6 100644 --- a/src/lib/completion/render-bash.ts +++ b/src/lib/completion/render-bash.ts @@ -1,10 +1,10 @@ import { - type CompletionFlag, - type CompletionSpec, + type CommandFlag, + type CommandSpec, flagTokens, -} from './completion-spec.js' +} from '../command-spec.js' -export const renderBashCompletion = (spec: CompletionSpec): string => { +export const renderBashCompletion = (spec: CommandSpec): string => { const globalTokens = spec.globalFlags.flatMap(flagTokens).sort() const valuelessTokens = spec.globalFlags .filter(({ takesValue }) => !takesValue) @@ -87,13 +87,13 @@ interface Branch { words: string[] } -const subcommandBranches = (spec: CompletionSpec): Branch[] => +const subcommandBranches = (spec: CommandSpec): Branch[] => spec.groups.map((group) => ({ pattern: group.path.join(' '), words: group.subcommands.map(({ name }) => name), })) -const flagBranches = (spec: CompletionSpec): Branch[] => +const flagBranches = (spec: CommandSpec): Branch[] => spec.commands .filter(({ flags }) => flags.length > 0) .map((command) => ({ @@ -101,7 +101,7 @@ const flagBranches = (spec: CompletionSpec): Branch[] => words: command.flags.flatMap(flagTokens), })) -const flagValueBranches = (spec: CompletionSpec): Branch[] => +const flagValueBranches = (spec: CommandSpec): Branch[] => spec.commands.flatMap((command) => command.flags.filter(hasValues).flatMap((flag) => flagTokens(flag).map((token) => ({ @@ -111,7 +111,7 @@ const flagValueBranches = (spec: CompletionSpec): Branch[] => ), ) -const hasValues = (flag: CompletionFlag): boolean => flag.values.length > 0 +const hasValues = (flag: CommandFlag): boolean => flag.values.length > 0 const renderCase = (name: string, branches: Branch[]): string => [ diff --git a/src/lib/completion/render-fish.ts b/src/lib/completion/render-fish.ts index 475d1b3f..803419b3 100644 --- a/src/lib/completion/render-fish.ts +++ b/src/lib/completion/render-fish.ts @@ -1,6 +1,7 @@ -import type { CompletionFlag, CompletionSpec } from './completion-spec.js' +import type { CommandFlag, CommandSpec } from '../command-spec.js' +import { describeForShell } from './describe.js' -export const renderFishCompletion = (spec: CompletionSpec): string => +export const renderFishCompletion = (spec: CommandSpec): string => `${[ header, helpers, @@ -38,7 +39,7 @@ function __seam_using --description 'Test whether the command line names the giv test "$command" = "$argv[1]" end` -const subcommandCompletions = (spec: CompletionSpec): string[] => +const subcommandCompletions = (spec: CommandSpec): string[] => spec.groups.flatMap((group) => group.subcommands.map(({ name, description }) => complete([ @@ -49,7 +50,7 @@ const subcommandCompletions = (spec: CompletionSpec): string[] => ), ) -const flagCompletions = (spec: CompletionSpec): string[] => +const flagCompletions = (spec: CommandSpec): string[] => spec.commands.flatMap((command) => command.flags.map((flag) => complete([ @@ -59,10 +60,10 @@ const flagCompletions = (spec: CompletionSpec): string[] => ), ) -const globalFlagCompletions = (spec: CompletionSpec): string[] => +const globalFlagCompletions = (spec: CommandSpec): string[] => spec.globalFlags.map((flag) => complete(flagOptions(flag))) -const flagOptions = (flag: CompletionFlag): string[] => [ +const flagOptions = (flag: CommandFlag): string[] => [ ...(flag.short == null ? [] : [`-s ${flag.short}`]), ...(flag.long == null ? [] : [`-l ${flag.long}`]), ...(flag.takesValue ? ['-r'] : []), @@ -70,8 +71,10 @@ const flagOptions = (flag: CompletionFlag): string[] => [ describe(flag.description), ] -const describe = (description: string): string => - description === '' ? '' : `-d '${description}'` +const describe = (description: string): string => { + const summary = describeForShell(description) + return summary === '' ? '' : `-d '${summary}'` +} const complete = (options: string[]): string => ['complete -c seam', ...options.filter((option) => option !== '')].join(' ') diff --git a/src/lib/completion/render-zsh.ts b/src/lib/completion/render-zsh.ts index 9511d38b..0f0f0d0d 100644 --- a/src/lib/completion/render-zsh.ts +++ b/src/lib/completion/render-zsh.ts @@ -1,10 +1,11 @@ import { - type CompletionFlag, - type CompletionSpec, + type CommandFlag, + type CommandSpec, flagTokens, -} from './completion-spec.js' +} from '../command-spec.js' +import { describeForShell } from './describe.js' -export const renderZshCompletion = (spec: CompletionSpec): string => { +export const renderZshCompletion = (spec: CommandSpec): string => { const valuelessTokens = spec.globalFlags .filter(({ takesValue }) => !takesValue) .flatMap(flagTokens) @@ -98,7 +99,7 @@ interface Branch { entries: string[] } -const subcommandBranches = (spec: CompletionSpec): Branch[] => +const subcommandBranches = (spec: CommandSpec): Branch[] => spec.groups.map((group) => ({ pattern: group.path.join(' '), entries: group.subcommands.map(({ name, description }) => @@ -106,7 +107,7 @@ const subcommandBranches = (spec: CompletionSpec): Branch[] => ), })) -const flagBranches = (spec: CompletionSpec): Branch[] => +const flagBranches = (spec: CommandSpec): Branch[] => spec.commands .filter(({ flags }) => flags.length > 0) .map((command) => ({ @@ -114,7 +115,7 @@ const flagBranches = (spec: CompletionSpec): Branch[] => entries: [describeFlags(command.flags)], })) -const flagValueBranches = (spec: CompletionSpec): Branch[] => +const flagValueBranches = (spec: CommandSpec): Branch[] => spec.commands.flatMap((command) => command.flags .filter(({ values }) => values.length > 0) @@ -126,15 +127,17 @@ const flagValueBranches = (spec: CompletionSpec): Branch[] => ), ) -const describeFlags = (flags: CompletionFlag[]): string => +const describeFlags = (flags: CommandFlag[]): string => flags .flatMap((flag) => flagTokens(flag).map((token) => describe(token, flag.description)), ) .join(' ') -const describe = (value: string, description: string): string => - description === '' ? `'${value}'` : `'${value}:${description}'` +const describe = (value: string, description: string): string => { + const summary = describeForShell(description) + return summary === '' ? `'${value}'` : `'${value}:${summary}'` +} const renderCase = (name: string, branches: Branch[]): string => [ diff --git a/src/lib/render-help.test.ts b/src/lib/render-help.test.ts new file mode 100644 index 00000000..f6ff5762 --- /dev/null +++ b/src/lib/render-help.test.ts @@ -0,0 +1,73 @@ +import { expect, test } from 'vitest' + +import { testBlueprint } from '../../test/fixtures/blueprint.js' +import { getCommandSpec } from './command-spec.js' +import { renderHelp } from './render-help.js' + +const spec = getCommandSpec(testBlueprint) + +const help = (...path: string[]): string => { + const rendered = renderHelp(path, spec) + if (rendered == null) throw new Error(`No help for seam ${path.join(' ')}`) + return rendered +} + +/** The guide wraps prose to the terminal, so match it without its layout. */ +const helpText = (...path: string[]): string => + help(...path).replace(/\s+/g, ' ') + +test('root help: lists every top level command', () => { + const rendered = helpText() + expect(rendered).toContain('Seam CLI') + expect(rendered).toContain('seam [options]') + // Both blueprint routes and commands handled by the CLI itself. + expect(rendered).toContain('devices') + expect(rendered).toContain('login') + expect(rendered).toContain('completion') + expect(rendered).toContain('Command List Examples') + expect(rendered).toContain("Run 'seam --help'") +}) + +test('group help: lists the subcommands of the group', () => { + const rendered = helpText('devices') + expect(rendered).toContain('seam devices [options]') + expect(rendered).toContain('List Devices') + expect(rendered).toContain('unmanaged') + // A group is not the place for the whole command list. + expect(rendered).not.toContain('Command List Examples') + expect(rendered).not.toContain('login') +}) + +test('group help: works for a nested group', () => { + const rendered = helpText('devices', 'unmanaged') + expect(rendered).toContain('seam devices unmanaged [options]') + expect(rendered).toContain('Gets an unmanaged device.') +}) + +test('command help: documents the flags of the command', () => { + const rendered = helpText('devices', 'list') + expect(rendered).toContain('seam devices list [options]') + expect(rendered).toContain('Returns a list of all devices.') + expect(rendered).toContain('--limit') + expect(rendered).toContain('Number of devices to return.') + expect(rendered).toContain('--device-type') + // Global flags stay available on every command. + expect(rendered).toContain('--help') + expect(rendered).toContain('-y') +}) + +test('command help: marks required flags and documents known values', () => { + expect(helpText('devices', 'unmanaged', 'get')).toContain('[required]') + expect(helpText('devices', 'list')).toContain( + 'One of: august_lock, schlage_lock.', + ) +}) + +test('command help: a command has no subcommands to list', () => { + expect(helpText('devices', 'list')).not.toContain('') +}) + +test('help: is absent for an unknown command path', () => { + expect(renderHelp(['nope'], spec)).toBeNull() + expect(renderHelp(['devices', 'nope'], spec)).toBeNull() +}) diff --git a/src/lib/render-help.ts b/src/lib/render-help.ts new file mode 100644 index 00000000..0687fd41 --- /dev/null +++ b/src/lib/render-help.ts @@ -0,0 +1,149 @@ +import commandLineUsage, { type Section } from 'command-line-usage' + +import { + type CommandDefinition, + type CommandFlag, + type CommandGroup, + type CommandSpec, + findCommand, + findGroup, +} from './command-spec.js' + +/** + * Render the help guide for a command path, or `null` when no command or + * group goes by that path. + * + * An empty path is the guide for `seam` itself. + */ +export const renderHelp = ( + path: string[], + spec: CommandSpec, +): string | null => { + const group = findGroup(spec, path) + if (group != null) return commandLineUsage(groupSections(group, spec)) + + const command = findCommand(spec, path) + if (command != null) return commandLineUsage(commandSections(command, spec)) + + return null +} + +const overview = + 'Every seam command is interactive and will prompt you for any missing required properties with helpful suggestions. To avoid automatic behavior, pass -y' + +const examples = [ + { name: 'seam', summary: 'Interactively select commands to execute.' }, + { name: 'seam login', summary: 'Login to Seam.' }, + { name: 'seam select workspace', summary: 'Select your workspace.' }, + { + name: 'seam connect-webviews create', + summary: 'Create a connect webview to connect devices.', + }, + { name: 'seam devices list', summary: 'List devices in your workspace.' }, + { + name: 'seam locks unlock-door {bold --device-id} $MY_DOOR', + summary: 'Unlock a lock.', + }, + { + name: "seam access-codes create {bold --code} '1234' {bold --name} 'My Code'", + summary: 'Create an access code.', + }, + { + name: 'seam completion bash', + summary: 'Print a shell completion script for bash, fish, or zsh.', + }, +] + +const groupSections = (group: CommandGroup, spec: CommandSpec): Section[] => { + const isRoot = group.path.length === 0 + const name = ['seam', ...group.path].join(' ') + + return [ + isRoot + ? { header: 'Seam CLI', content: overview } + : { header: name, content: `Commands under ${name}.` }, + { header: 'Usage', content: `${name} [options]` }, + { + header: 'Commands', + content: group.subcommands.map(({ name, description }) => ({ + name, + summary: description, + })), + }, + optionSection(spec.globalFlags), + ...(isRoot ? [{ header: 'Command List Examples', content: examples }] : []), + { content: `Run '${name} --help' to see a command in detail.` }, + ] +} + +const commandSections = ( + command: CommandDefinition, + spec: CommandSpec, +): Section[] => { + const name = ['seam', ...command.path].join(' ') + const hasFlags = command.flags.length > 0 + + return [ + { + header: name, + content: [command.title, command.description].filter( + (line) => line !== '', + ), + }, + { header: 'Usage', content: `${name} [options]` }, + optionSection([...command.flags, ...spec.globalFlags]), + ...(hasFlags + ? [ + { + content: + 'Any required option left out is prompted for interactively.', + }, + ] + : []), + ] +} + +const optionSection = (flags: CommandFlag[]): Section => ({ + header: 'Options', + optionList: flags.map(toOptionDefinition), +}) + +const maxDocumentedValues = 8 + +interface OptionDefinition { + name: string + alias?: string + description: string + type: typeof Boolean | typeof String + typeLabel?: string +} + +const toOptionDefinition = (flag: CommandFlag): OptionDefinition => { + const description = [ + flag.isRequired ? '{bold [required]}' : '', + flag.description, + describeValues(flag), + ] + .filter((part) => part !== '') + .join(' ') + + return { + // command-line-usage renders a nameless option as the short form alone. + name: flag.long ?? '', + ...(flag.short == null ? {} : { alias: flag.short }), + // A flag with no value must be typed as a boolean, or the guide labels it + // as taking a string. + type: flag.takesValue ? String : Boolean, + ...(flag.takesValue ? { typeLabel: '{underline value}' } : {}), + description, + } +} + +const describeValues = (flag: CommandFlag): string => { + if (flag.values.length === 0) return '' + + const shown = flag.values.slice(0, maxDocumentedValues).join(', ') + const rest = flag.values.length - maxDocumentedValues + + return rest > 0 ? `One of: ${shown}, and ${rest} more.` : `One of: ${shown}.` +} diff --git a/test/fixtures/blueprint.ts b/test/fixtures/blueprint.ts new file mode 100644 index 00000000..802c6095 --- /dev/null +++ b/test/fixtures/blueprint.ts @@ -0,0 +1,58 @@ +import type { Blueprint } from '@seamapi/blueprint' + +/** + * A blueprint with just enough shape to derive a command spec from, standing + * in for the API definitions bundled with the CLI. + */ +export const testBlueprint = { + routes: [ + { + endpoints: [ + { + path: '/devices/list', + title: 'List Devices', + description: + 'Returns a list of all [devices](https://docs.seam.co). Results are paginated.', + request: { + parameters: [ + { + name: 'limit', + description: 'Number of devices to return.', + format: 'number', + isRequired: false, + }, + { + name: 'device_type', + description: 'Device type: for which you want to list devices.', + format: 'enum', + isRequired: false, + values: [{ name: 'august_lock' }, { name: 'schlage_lock' }], + }, + { + name: 'is_managed', + description: "Whether the device's account is managed.", + format: 'boolean', + isRequired: false, + }, + ], + }, + }, + { + path: '/devices/unmanaged/get', + title: '', + description: 'Gets an unmanaged device. Only some fields are set.', + request: { + parameters: [ + { + name: 'device_id', + description: 'ID of the device.', + format: 'id', + isRequired: true, + }, + ], + }, + }, + ], + }, + ], +} as unknown as Blueprint