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