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..938b917c 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,60 @@ 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 +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..0b45b5e4 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -3,10 +3,15 @@ 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, + 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' @@ -17,61 +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.', - }, - ], - }, -] - 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 } @@ -80,6 +61,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' && @@ -105,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] } @@ -240,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/command-spec.ts b/src/lib/command-spec.ts new file mode 100644 index 00000000..b5a8d9c6 --- /dev/null +++ b/src/lib/command-spec.ts @@ -0,0 +1,331 @@ +import type { Blueprint } from '@seamapi/blueprint' + +type Endpoint = Blueprint['routes'][number]['endpoints'][number] +type Parameter = Endpoint['request']['parameters'][number] + +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 and document its argument. */ + values: string[] + /** Whether the flag is followed by a value. */ + takesValue: boolean + isRequired: boolean +} + +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: CommandFlag[] +} + +export interface Subcommand { + name: string + description: string +} + +export interface CommandGroup { + /** Command path completed by this group, empty for `seam` itself. */ + path: string[] + subcommands: Subcommand[] +} + +export interface CommandSpec { + /** Every invocable command, sorted by command path. */ + commands: CommandDefinition[] + /** Every incomplete command path, sorted by command path. */ + groups: CommandGroup[] + /** Flags accepted regardless of the command. */ + globalFlags: CommandFlag[] +} + +export const globalFlags: CommandFlag[] = [ + { + long: 'help', + short: 'h', + description: 'Display this help guide.', + values: [], + takesValue: false, + isRequired: false, + }, + { + long: 'remote-api-defs', + short: null, + description: 'Use the API definitions served by the Seam API.', + values: [], + takesValue: false, + isRequired: false, + }, + { + long: 'version', + short: null, + description: 'Print the CLI version.', + values: [], + takesValue: false, + isRequired: false, + }, + { + long: null, + short: 'y', + description: 'Take the first suggestion instead of prompting.', + values: [], + takesValue: false, + isRequired: false, + }, +] + +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 getCommandSpec = (blueprint: Blueprint): CommandSpec => { + const commands = sortByPath( + dedupeByPath([ + ...blueprint.routes + .flatMap((route) => route.endpoints) + .map(toCommandDefinition), + ...localCommands, + ]), + ) + + return { commands, groups: toCommandGroups(commands), globalFlags } +} + +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, +}) + +/** + * 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: CommandDefinition[] = [ + { + path: ['completion', 'bash'], + title: 'Print the bash completion script.', + description: '', + flags: [], + }, + { + path: ['completion', 'fish'], + title: 'Print the fish completion script.', + description: '', + flags: [], + }, + { + path: ['completion', 'zsh'], + title: 'Print the zsh completion script.', + description: '', + flags: [], + }, + { + path: ['config', 'reveal-location'], + title: 'Print the path to the CLI configuration file.', + description: '', + flags: [], + }, + { + path: ['config', 'use-remote-api-defs'], + title: 'Choose whether to use the API definitions served by Seam.', + description: '', + flags: [], + }, + { + path: ['health', 'get-health'], + title: 'Report the health of the Seam API.', + description: '', + flags: [], + }, + { + path: ['login'], + 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.'), + stringFlag('workspace-id', 'Workspace to select after logging in.'), + ], + }, + { + path: ['logout'], + title: 'Log out of Seam.', + description: '', + flags: [], + }, + { + path: ['select', 'server'], + title: 'Select the Seam API server.', + description: '', + flags: [stringFlag('server', 'Seam API server to select.')], + }, + { + path: ['select', 'workspace'], + title: 'Select the current workspace.', + description: '', + flags: [], + }, +] + +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 toCommandFlag = (parameter: Parameter): CommandFlag => ({ + long: toFlagName(parameter.name), + short: null, + description: toPlainText(parameter.description), + values: toFlagValues(parameter), + takesValue: true, + isRequired: parameter.isRequired, +}) + +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) + +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 key = command.path.slice(0, depth).join(' ') + + 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 + // 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 (!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, entries]) => ({ + path: key === '' ? [] : key.split(' '), + 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: CommandDefinition[]): CommandDefinition[] => { + 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: CommandDefinition[]): CommandDefinition[] => + [...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, '-') + +/** 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 sentence ?? text +} diff --git a/src/lib/completion/completion.test.ts b/src/lib/completion/completion.test.ts new file mode 100644 index 00000000..f0630a88 --- /dev/null +++ b/src/lib/completion/completion.test.ts @@ -0,0 +1,67 @@ +import { expect, test } from 'vitest' + +import { testBlueprint } from '../../../test/fixtures/blueprint.js' +import { describeForShell } from './describe.js' +import { + completionShells, + isCompletionShell, + renderCompletion, +} from './index.js' + +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', testBlueprint) + 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', 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.'") + 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', testBlueprint) + 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, 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 new file mode 100644 index 00000000..5f2b78f4 --- /dev/null +++ b/src/lib/completion/index.ts @@ -0,0 +1,31 @@ +import type { Blueprint } from '@seamapi/blueprint' + +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' + +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](getCommandSpec(blueprint)) diff --git a/src/lib/completion/render-bash.ts b/src/lib/completion/render-bash.ts new file mode 100644 index 00000000..6f8ba1f6 --- /dev/null +++ b/src/lib/completion/render-bash.ts @@ -0,0 +1,125 @@ +import { + type CommandFlag, + type CommandSpec, + flagTokens, +} from '../command-spec.js' + +export const renderBashCompletion = (spec: CommandSpec): 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: CommandSpec): Branch[] => + spec.groups.map((group) => ({ + pattern: group.path.join(' '), + words: group.subcommands.map(({ name }) => name), + })) + +const flagBranches = (spec: CommandSpec): Branch[] => + spec.commands + .filter(({ flags }) => flags.length > 0) + .map((command) => ({ + pattern: command.path.join(' '), + words: command.flags.flatMap(flagTokens), + })) + +const flagValueBranches = (spec: CommandSpec): 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: CommandFlag): 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..803419b3 --- /dev/null +++ b/src/lib/completion/render-fish.ts @@ -0,0 +1,80 @@ +import type { CommandFlag, CommandSpec } from '../command-spec.js' +import { describeForShell } from './describe.js' + +export const renderFishCompletion = (spec: CommandSpec): 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: CommandSpec): string[] => + spec.groups.flatMap((group) => + group.subcommands.map(({ name, description }) => + complete([ + `-n '__seam_using "${group.path.join(' ')}"'`, + `-a '${name}'`, + describe(description), + ]), + ), + ) + +const flagCompletions = (spec: CommandSpec): string[] => + spec.commands.flatMap((command) => + command.flags.map((flag) => + complete([ + `-n '__seam_using "${command.path.join(' ')}"'`, + ...flagOptions(flag), + ]), + ), + ) + +const globalFlagCompletions = (spec: CommandSpec): string[] => + spec.globalFlags.map((flag) => complete(flagOptions(flag))) + +const flagOptions = (flag: CommandFlag): 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 => { + 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 new file mode 100644 index 00000000..0f0f0d0d --- /dev/null +++ b/src/lib/completion/render-zsh.ts @@ -0,0 +1,152 @@ +import { + type CommandFlag, + type CommandSpec, + flagTokens, +} from '../command-spec.js' +import { describeForShell } from './describe.js' + +export const renderZshCompletion = (spec: CommandSpec): 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: CommandSpec): Branch[] => + spec.groups.map((group) => ({ + pattern: group.path.join(' '), + entries: group.subcommands.map(({ name, description }) => + describe(name, description), + ), + })) + +const flagBranches = (spec: CommandSpec): Branch[] => + spec.commands + .filter(({ flags }) => flags.length > 0) + .map((command) => ({ + pattern: command.path.join(' '), + entries: [describeFlags(command.flags)], + })) + +const flagValueBranches = (spec: CommandSpec): 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: CommandFlag[]): string => + flags + .flatMap((flag) => + flagTokens(flag).map((token) => describe(token, flag.description)), + ) + .join(' ') + +const describe = (value: string, description: string): string => { + const summary = describeForShell(description) + return summary === '' ? `'${value}'` : `'${value}:${summary}'` +} + +const renderCase = (name: string, branches: Branch[]): string => + [ + `${name}() {`, + ` case "$1" in`, + ...branches.map( + ({ pattern, entries }) => + ` ('${pattern}') _seam_reply+=(${entries.join(' ')}) ;;`, + ), + ` esac`, + `}`, + ].join('\n') 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