diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index 8614f267..2e0f66c8 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -46,9 +46,12 @@ jobs: fi bun build src/bin/cli.ts --compile --minify --target="bun-${platform}" --outfile="$binary" done - - name: Generate standalone binary checksums + - name: Add shell completion loaders + # Generated by prepack during npm pack. + run: cp completions/seam.bash completions/seam.fish completions/seam.zsh release/ + - name: Generate checksums working-directory: release - run: sha256sum seam-* > checksums.txt + run: sha256sum seam* > checksums.txt - name: Upload artifact uses: actions/upload-artifact@v7 with: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8717bae8..0fa456c2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -43,7 +43,7 @@ jobs: prerelease: ${{ contains(github.ref_name, '-') }} files: | *.tgz - release/seam-* + release/seam* release/checksums.txt body_path: ${{ github.workspace }}/${{ steps.changelog.outputs.outfile }} npm: @@ -108,18 +108,25 @@ jobs: url="${url}" license=('MIT') depends=('glibc' 'gcc-libs') + optdepends=('bash-completion: completions for bash') provides=('seam') conflicts=('seam') options=('!strip' '!debug') - source=("\${url}/raw/v\${pkgver}/LICENSE.txt") + source=("\${url}/raw/v\${pkgver}/LICENSE.txt" + "seam-\${pkgver}.bash::\${url}/releases/download/v\${pkgver}/seam.bash" + "seam-\${pkgver}.fish::\${url}/releases/download/v\${pkgver}/seam.fish" + "seam-\${pkgver}.zsh::\${url}/releases/download/v\${pkgver}/seam.zsh") source_x86_64=("\${pkgname}-\${pkgver}-x86_64::\${url}/releases/download/v\${pkgver}/seam-v\${pkgver}-linux-x64") source_aarch64=("\${pkgname}-\${pkgver}-aarch64::\${url}/releases/download/v\${pkgver}/seam-v\${pkgver}-linux-arm64") - sha256sums=('SKIP') + sha256sums=('SKIP' 'SKIP' 'SKIP' 'SKIP') sha256sums_x86_64=('SKIP') sha256sums_aarch64=('SKIP') package() { install -Dm755 "\${pkgname}-\${pkgver}-\${CARCH}" "\${pkgdir}/usr/bin/seam" + install -Dm644 "seam-\${pkgver}.bash" "\${pkgdir}/usr/share/bash-completion/completions/seam" + install -Dm644 "seam-\${pkgver}.fish" "\${pkgdir}/usr/share/fish/vendor_completions.d/seam.fish" + install -Dm644 "seam-\${pkgver}.zsh" "\${pkgdir}/usr/share/zsh/site-functions/_seam" install -Dm644 LICENSE.txt "\${pkgdir}/usr/share/licenses/\${pkgname}/LICENSE" } PKGBUILD 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 6f323562..a096e884 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,66 @@ Missing required parameter for /locks/unlock_door: --device-id An error exits non-zero. A request that fails reports its `error` on stdout, so it can be inspected from a pipe; anything else is written to stderr only. +## 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" +``` + +System packages install completion loaders instead: small scripts packaged +under `completions/` in the published package and attached to each +[GitHub release]. A loader runs `seam completion` the first time the shell +completes a seam command, so installed completions always match the CLI's +current Seam API definitions and never go stale between package updates. The +`seam-bin` AUR package installs the loaders for all three shells. + +Completions are generated from the cached Seam API definitions, so they may +briefly lag a newly released API. Pass `--update` to refresh the cache first, +e.g., `seam completion bash --update`. They do not reflect definitions served +by another Seam API server when `seam config use-remote-api-defs` is enabled. + +[GitHub release]: https://github.com/seamapi/cli/releases/latest + ## Development and Testing ### Quickstart diff --git a/package.json b/package.json index 4beed2a0..ee68cd03 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 4cac1cc7..b5a9bc84 100644 --- a/prepack.ts +++ b/prepack.ts @@ -1,9 +1,17 @@ -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 { $ } from 'execa' +import { + completionFileNames, + completionShells, + renderCompletionStub, +} from './src/lib/completion/index.js' + const versionFile = './src/lib/version.ts' +const completionsDirectory = './completions' const main = async (): Promise => { const version = await injectVersion(resolveFile(versionFile)) @@ -18,11 +26,29 @@ const main = async (): Promise => { `✓ Blueprint version ${blueprintVersion} injected into ${versionFile}`, ) + await writeCompletions(resolveFile(completionsDirectory)) + // eslint-disable-next-line no-console + console.log(`✓ Shell completion loaders 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): Promise => { + await mkdir(path, { recursive: true }) + + await Promise.all( + completionShells.map(async (shell) => { + await writeFile( + join(path, completionFileNames[shell]), + renderCompletionStub(shell), + 'utf8', + ) + }), + ) +} + const injectVersion = async (path: string): Promise => { const { version } = await readPackageJson() diff --git a/src/bin/cli.ts b/src/bin/cli.ts index a9f7abed..5fd2395b 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -3,9 +3,14 @@ import { randomBytes } from 'node:crypto' import { isDeepStrictEqual as isEqual } from 'node:util' import chalk from 'chalk' -import commandLineUsage from 'command-line-usage' import type { ParsedArgs } from 'minimist' +import { getCommandSpec } from 'lib/command-spec.js' +import { + completionShells, + isCompletionShell, + renderCompletion, +} from 'lib/completion/index.js' import { getConfigStore } from 'lib/config/index.js' import { getApiBlueprint } from 'lib/get-api-blueprint.js' import { getResponseKey } from 'lib/get-response-key.js' @@ -20,6 +25,7 @@ import { interactForWorkspaceId } from 'lib/interact-for-workspace-id.js' import { createOutput } from 'lib/output/create-output.js' import { getOutput, setOutput } from 'lib/output/get-output.js' import { resolveOutputFormat } from 'lib/output/resolve-output-format.js' +import { renderHelp } from 'lib/render-help.js' import type { ContextHelpers } from 'lib/types.js' import { cliFlags, @@ -34,109 +40,35 @@ 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 runs as soon as every required property is given, and otherwise prompts you for what is missing with helpful suggestions. Pass -i to always review properties first, or -y to never be prompted. ', - }, - { - header: 'Options', - optionList: [ - { - name: 'help', - description: 'Display this help guide.', - alias: 'h', - type: Boolean, - }, - { - name: 'interactive', - description: - 'Always prompt to review and edit properties, prefilled with the given arguments.', - alias: 'i', - type: Boolean, - }, - { - name: 'non-interactive', - description: - 'Never prompt: exit with an error if the command or any required property is missing.', - alias: 'y', - type: Boolean, - }, - { - name: 'json', - description: - 'Write the response to stdout as JSON. Enabled automatically when stdout is not a terminal, disable with {bold --no-json}.', - type: Boolean, - }, - { - name: 'update', - description: 'Force an update of the cached Seam API definitions.', - type: Boolean, - }, - ], - }, - { - header: 'Output', - content: [ - 'Only the response is written to stdout, so it is safe to pipe. Prompts, progress, and other information are written to stderr.', - 'The response is trimmed to the response key and pagination.', - 'Request params may be piped or redirected in as a JSON object. Params given as arguments win over params read from stdin.', - ], - }, - { - header: 'Command List Examples', - content: [ - { name: 'seam', summary: 'Interactively select commands to execute.' }, - { name: 'seam login', summary: 'Login to Seam.' }, - { - name: 'seam wizard', - summary: 'Set up Seam in the current project.', - }, - { 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 devices list {bold --interactive}', - summary: 'Review and edit filters before listing devices.', - }, - { - name: 'seam devices list {bold --non-interactive}', - summary: 'List devices, failing instead of prompting.', - }, - { - 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 devices list > devices.json', - summary: 'Write the response to a file as JSON.', - }, - { - name: 'cat params.json | seam locks unlock-door', - summary: 'Pipe request params in as JSON.', - }, - ], - }, -] - async function cli(args: ParsedArgs) { const config = getConfigStore() const output = getOutput() - if (args['help'] || args['h']) { - output.text(commandLineUsage(sections)) + const update = args['update'] === true + + const helpFlag = args['help'] ?? args['h'] + if (helpFlag != null) { + // Help comes from the cached API definitions so that it works without + // logging in, and offline once the cache is warm. + const spec = getCommandSpec(await getApiBlueprint(false, { update })) + + // 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) { + output.error(chalk.red(`Unknown command: seam ${commandPath.join(' ')}`)) + output.error(`Run 'seam --help' to see the available commands.`) + process.exitCode = 1 + return + } + + output.text(help) return } @@ -145,6 +77,24 @@ async function cli(args: ParsedArgs) { return } + if (args._[0] === 'completion') { + const shell = args._[1] + + if (!isCompletionShell(shell)) { + output.error(`Usage: seam completion <${completionShells.join('|')}>`) + process.exitCode = 1 + return + } + + // Completions always come from the cached API definitions so that they + // can be generated without logging in. They may lag the definitions + // served by Seam when config use-remote-api-defs is enabled. + output.text( + renderCompletion(shell, await getApiBlueprint(false, { update })), + ) + return + } + if ( args._[0] === 'config' && args._[1] === 'set' && @@ -171,7 +121,7 @@ async function cli(args: ParsedArgs) { return } - args._ = args._.map((arg) => arg.toLowerCase().replace(/_/g, '-')) + args._ = args._.map(toCommandWord) for (const k in args) { args[k.toLowerCase().replace(/-/g, '_')] = args[k] } @@ -179,9 +129,6 @@ async function cli(args: ParsedArgs) { const use_remote_api_defs = args['remote_api_defs'] ?? config.get('use_remote_api_defs') - const update = args['update'] === true - delete args['update'] - const blueprint = await getApiBlueprint(use_remote_api_defs ?? false, { update, }) @@ -336,6 +283,9 @@ async function cli(args: ParsedArgs) { } } +const toCommandWord = (arg: string): string => + arg.toLowerCase().replace(/_/g, '-') + const handleConnectWebviewResponse = async ( connect_webview: any, interactivity: Interactivity, diff --git a/src/lib/command-spec.test.ts b/src/lib/command-spec.test.ts new file mode 100644 index 00000000..2a8e0af1 --- /dev/null +++ b/src/lib/command-spec.test.ts @@ -0,0 +1,174 @@ +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', kind: 'api', 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: tells CLI commands apart from API commands', () => { + expect(findCommand(spec, ['login'])?.kind).toBe('cli') + expect(findCommand(spec, ['devices', 'list'])?.kind).toBe('api') + // health is handled by the CLI but calls the Seam API. + expect(findCommand(spec, ['health', 'get-health'])?.kind).toBe('api') + + const root = findGroup(spec, [])?.subcommands ?? [] + const kindOf = (name: string): string | undefined => + root.find((sub) => sub.name === name)?.kind + expect(kindOf('select')).toBe('cli') + expect(kindOf('wizard')).toBe('cli') + expect(kindOf('devices')).toBe('api') + expect(kindOf('health')).toBe('api') +}) + +test('command spec: never emits names a shell could read as syntax', () => { + const hostile = { + routes: [ + { + endpoints: [ + { + path: "/devices'; ls /; '/list", + title: 'Hostile Path', + description: '', + request: { parameters: [] }, + }, + { + path: '/devices/list', + title: 'List Devices', + description: '', + request: { + parameters: [ + { + name: "limit'; ls /; '", + description: '', + format: 'number', + isRequired: false, + }, + { + name: 'limit', + description: '', + format: 'number', + isRequired: false, + }, + ], + }, + }, + ], + }, + ], + } as unknown as Parameters[0] + + const hostileSpec = getCommandSpec(hostile) + const paths = hostileSpec.commands.map(({ path }) => path.join(' ')) + expect(paths.filter((path) => path.includes('ls /'))).toEqual([]) + expect(paths).toContain('devices list') + expect( + findCommand(hostileSpec, ['devices', 'list'])?.flags.map( + ({ long }) => long, + ), + ).toEqual(['limit']) +}) + +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..aa0f386d --- /dev/null +++ b/src/lib/command-spec.ts @@ -0,0 +1,400 @@ +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 +} + +/** + * Whether a command is part of the CLI itself or calls a Seam API endpoint. + */ +export type CommandKind = 'cli' | 'api' + +export interface CommandDefinition { + path: string[] + kind: CommandKind + /** 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 + /** 'api' when the name holds any command that calls the Seam API. */ + kind: CommandKind + 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: 'interactive', + short: 'i', + description: + 'Always prompt to review and edit properties, prefilled with the given arguments.', + values: [], + takesValue: false, + isRequired: false, + }, + { + long: 'json', + short: null, + description: + 'Write the response to stdout as JSON. Enabled automatically when stdout is not a terminal, disable with --no-json.', + values: [], + takesValue: false, + isRequired: false, + }, + { + long: 'non-interactive', + short: 'y', + description: + 'Never prompt: exit with an error if the command or any required property is missing.', + 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: 'update', + short: null, + description: 'Force an update of the cached Seam API definitions.', + values: [], + takesValue: false, + isRequired: false, + }, + { + long: 'version', + short: null, + description: 'Print the CLI version.', + 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) + // Command and flag names end up unquoted or single-quoted in shell + // scripts, so drop any the definitions should never contain rather + // than emit something a shell could read as syntax. + .filter((command) => command.path.every(isSafeToken)), + ...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'], + kind: 'cli', + title: 'Print the bash completion script.', + description: '', + flags: [], + }, + { + path: ['completion', 'fish'], + kind: 'cli', + title: 'Print the fish completion script.', + description: '', + flags: [], + }, + { + path: ['completion', 'zsh'], + kind: 'cli', + title: 'Print the zsh completion script.', + description: '', + flags: [], + }, + { + path: ['config', 'reveal-location'], + kind: 'cli', + title: 'Print the path to the CLI configuration file.', + description: '', + flags: [], + }, + { + path: ['config', 'use-remote-api-defs'], + kind: 'cli', + title: 'Choose whether to use the API definitions served by Seam.', + description: '', + flags: [], + }, + { + path: ['health', 'get-health'], + kind: 'api', + title: 'Report the health of the Seam API.', + description: '', + flags: [], + }, + { + path: ['login'], + kind: 'cli', + 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'], + kind: 'cli', + title: 'Log out of Seam.', + description: '', + flags: [], + }, + { + path: ['select', 'server'], + kind: 'cli', + title: 'Select the Seam API server.', + description: '', + flags: [stringFlag('server', 'Seam API server to select.')], + }, + { + path: ['select', 'workspace'], + kind: 'cli', + title: 'Select the current workspace.', + description: '', + flags: [], + }, + { + path: ['wizard'], + kind: 'cli', + title: 'Set up Seam in the current project.', + description: + 'Takes a project from zero to a working Seam integration. Run seam wizard --help for its own options.', + flags: [], + }, +] + +const toCommandDefinition = (endpoint: Endpoint): CommandDefinition => { + const description = toPlainText(endpoint.description) + + return { + path: toCommandPath(endpoint.path), + kind: 'api', + title: + endpoint.title === '' + ? firstSentence(description) + : toPlainText(endpoint.title), + description, + flags: [...endpoint.request.parameters] + .map(toCommandFlag) + .filter((flag) => flag.long == null || isSafeToken(flag.long)) + .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 is safe to write into a shell script. Command, flag, and + * enum names come from the API definitions and are embedded unquoted or + * single-quoted in completion scripts, so never emit one that a shell could + * read as syntax. + */ +const isSafeToken = (token: string): boolean => /^[\w.:@/+-]+$/.test(token) + +interface GroupEntry { + isCommand: boolean + kind: CommandKind + 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) + + // An entry is an API command if any command it holds calls the API. + const kind = + command.kind === 'api' ? 'api' : (entries.get(name)?.kind ?? 'cli') + + // 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, kind, description: command.title }) + continue + } + + const entry = entries.get(name) + entries.set(name, { + isCommand: entry?.isCommand ?? false, + kind, + description: entry?.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, + kind: entry.kind, + 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..f02df7d0 --- /dev/null +++ b/src/lib/completion/completion.test.ts @@ -0,0 +1,90 @@ +import { expect, test } from 'vitest' + +import { testBlueprint } from '../../../test/fixtures/blueprint.js' +import { describeForShell } from './describe.js' +import { + completionShells, + isCompletionShell, + renderCompletion, + renderCompletionStub, +} 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) +}) + +test.each(completionShells)( + '%s completion stub: loads completions from the CLI', + (shell) => { + const stub = renderCompletionStub(shell) + expect(stub).toContain(`seam completion ${shell}`) + expect(stub.endsWith('\n')).toBe(true) + }, +) + +test('zsh completion stub: is an autoloadable completion function', () => { + expect(renderCompletionStub('zsh').startsWith('#compdef seam\n')).toBe(true) +}) + +test('zsh completion: completes the in-flight request when evaluated by the stub', () => { + // eval pushes '(eval)' onto funcstack, so the dispatch must search the + // whole stack for _seam, not only the top. + expect(renderCompletion('zsh', testBlueprint)).toContain( + // eslint-disable-next-line no-template-curly-in-string + 'if (( ${funcstack[(I)_seam]} )); then', + ) +}) 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..43bc3c70 --- /dev/null +++ b/src/lib/completion/index.ts @@ -0,0 +1,82 @@ +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)) + +/** + * Render the completion loader installed by system packages. + * + * The loader runs 'seam completion' the first time the shell completes a seam + * command, so installed completions always match the CLI's current Seam API + * definitions instead of the definitions packaged at release time. Each shell + * loads its completion file on demand, so the CLI runs once per shell session + * at first completion, never at shell startup. + * + * The loader degrades to no completions when the seam command is missing or + * cannot produce a script, e.g., offline before the definitions are cached. + */ +export const renderCompletionStub = (shell: CompletionShell): string => + stubs[shell] + +const stubHeader = (shell: CompletionShell): string => + `# ${shell} completion loader for the seam command. +# +# Generated by @seamapi/cli. Loads completions from the CLI on first use, so +# they always match the CLI's current Seam API definitions. Requires the seam +# command on PATH. Print the underlying script with 'seam completion ${shell}'.` + +const stubs: Record = { + bash: `${stubHeader('bash')} +# +# Install to /usr/share/bash-completion/completions/seam + +if command -v seam > /dev/null 2>&1; then + eval "$(seam completion bash 2> /dev/null)" +fi +`, + fish: `${stubHeader('fish')} +# +# Install to /usr/share/fish/vendor_completions.d/seam.fish + +if command --query seam + seam completion fish 2> /dev/null | source +end +`, + zsh: `#compdef seam +${stubHeader('zsh')} +# +# Install to a directory in fpath as _seam + +# The generated script ends by dispatching on funcstack, so evaluating it +# while this autoloaded _seam runs both redefines _seam and completes the +# in-flight request. +eval "$(seam completion zsh 2> /dev/null)" +`, +} diff --git a/src/lib/completion/render-bash.ts b/src/lib/completion/render-bash.ts new file mode 100644 index 00000000..13278c0f --- /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 Seam API definitions. +# 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..201ea898 --- /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 Seam API definitions. +# 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..e057c629 --- /dev/null +++ b/src/lib/completion/render-zsh.ts @@ -0,0 +1,157 @@ +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 Seam API definitions. +# 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 +}` + +// The script runs in three ways. Autoloaded from fpath as _seam, it must +// complete the in-flight request: funcstack holds _seam. Evaluated by the +// loader stub inside the autoloaded _seam, the same applies, but eval pushes +// '(eval)' onto funcstack, so search the whole stack rather than the top. +// Sourced into a shell, funcstack holds no _seam: register with compdef. +const dispatch = `if (( \${funcstack[(I)_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..12142731 --- /dev/null +++ b/src/lib/render-help.test.ts @@ -0,0 +1,106 @@ +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('root help: groups CLI commands apart from API commands', () => { + const rendered = helpText() + + const commands = rendered.indexOf('Commands') + const apiCommands = rendered.indexOf('API Commands') + const examples = rendered.indexOf('Command List Examples') + expect(commands).toBeGreaterThan(-1) + expect(apiCommands).toBeGreaterThan(commands) + expect(examples).toBeGreaterThan(apiCommands) + + // login is a CLI command, devices calls the API. + expect(rendered.indexOf('login')).toBeLessThan(apiCommands) + expect(rendered.indexOf('devices')).toBeGreaterThan(apiCommands) +}) + +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('API Commands') + 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: keeps parameters apart from the CLI options', () => { + const rendered = helpText('devices', 'list') + + const parameters = rendered.indexOf('Parameters') + const options = rendered.indexOf('Options') + expect(parameters).toBeGreaterThan(-1) + expect(options).toBeGreaterThan(parameters) + + // The command's own parameters sit under Parameters, the CLI's flags + // under Options. + expect(rendered.indexOf('--limit')).toBeLessThan(options) + expect(rendered.indexOf('--version')).toBeGreaterThan(options) + + // A command with no parameters has no Parameters section. + expect(helpText('logout')).not.toContain('Parameters') +}) + +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..a7656037 --- /dev/null +++ b/src/lib/render-help.ts @@ -0,0 +1,197 @@ +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 runs as soon as every required property is given, and otherwise prompts you for what is missing with helpful suggestions. Pass -i to always review properties first, or -y to never be prompted.' + +const outputSection = { + header: 'Output', + content: [ + 'Only the response is written to stdout, so it is safe to pipe. Prompts, progress, and other information are written to stderr.', + 'The response is trimmed to the response key and pagination.', + 'Request params may be piped or redirected in as a JSON object. Params given as arguments win over params read from stdin.', + ], +} + +const examples = [ + { name: 'seam', summary: 'Interactively select commands to execute.' }, + { name: 'seam login', summary: 'Login to Seam.' }, + { name: 'seam wizard', summary: 'Set up Seam in the current project.' }, + { 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 devices list {bold --interactive}', + summary: 'Review and edit filters before listing devices.', + }, + { + name: 'seam devices list {bold --non-interactive}', + summary: 'List devices, failing instead of prompting.', + }, + { + 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 devices list > devices.json', + summary: 'Write the response to a file as JSON.', + }, + { + name: 'cat params.json | seam locks unlock-door', + summary: 'Pipe request params in as JSON.', + }, + { + 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]` }, + ...commandSectionsForGroup(group, isRoot), + optionSection(spec.globalFlags), + ...(isRoot + ? [outputSection, { header: 'Command List Examples', content: examples }] + : []), + { content: `Run '${name} --help' to see a command in detail.` }, + ] +} + +const commandSectionsForGroup = ( + group: CommandGroup, + isRoot: boolean, +): Section[] => { + const content = (subcommands: CommandGroup['subcommands']) => + subcommands.map(({ name, description }) => ({ name, summary: description })) + + // The root guide separates the commands of the CLI itself from the commands + // that call the Seam API. Anywhere deeper the split adds nothing: a group + // holds commands of one kind. + if (!isRoot) { + return [{ header: 'Commands', content: content(group.subcommands) }] + } + + const cli = group.subcommands.filter(({ kind }) => kind === 'cli') + const api = group.subcommands.filter(({ kind }) => kind === 'api') + + return [ + { header: 'Commands', content: content(cli) }, + { header: 'API Commands', content: content(api) }, + ].filter((section) => section.content.length > 0) +} + +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]` }, + // The command's own parameters are what the request is made of, so keep + // them apart from the options every seam command takes. + ...(hasFlags ? [optionSection(command.flags, 'Parameters')] : []), + optionSection(spec.globalFlags), + ...(hasFlags + ? [ + { + content: + 'Any required parameter left out is prompted for interactively.', + }, + ] + : []), + ] +} + +const optionSection = (flags: CommandFlag[], header = 'Options'): Section => ({ + header, + 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