From 6c2c32d67a20c7a6aa73fafad4120f5366c7e6c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 02:53:31 +0000 Subject: [PATCH 1/4] feat: Add --non-interactive flag with -n alias Non-interactive mode was only reachable via the undocumented -y flag. Add --non-interactive with a -n short flag as the documented spelling, keeping -y working as an alias. Interactivity flags are no longer forwarded to the API as request parameters. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TxQCkfVypobQVA7DpF4jvx --- README.md | 12 ++++++++---- src/bin/cli.ts | 35 ++++++++++++++++++++++++----------- src/lib/util/cli-args.test.ts | 32 ++++++++++++++++++++++++++++++++ src/lib/util/cli-args.ts | 22 ++++++++++++++++++++++ 4 files changed, 86 insertions(+), 15 deletions(-) create mode 100644 src/lib/util/cli-args.test.ts create mode 100644 src/lib/util/cli-args.ts diff --git a/README.md b/README.md index 2a057850..854bf699 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ A command line interface (CLI) for interacting with the Seam API. ## Description Every command is interactive: the CLI prompts for any missing required -parameter with suggestions pulled from your workspace. Pass `-y` to take the -first suggestion instead of being asked. +parameter with suggestions pulled from your workspace. Pass `--non-interactive` +(or `-n`) to take the first suggestion instead of being asked. ## Installation @@ -33,8 +33,9 @@ $ paru -S seam-bin ## Usage Every `seam` command is interactive and will prompt you for any missing -required properties with helpful suggestions. To avoid automatic behavior, -pass `-y` +required properties with helpful suggestions. To skip the prompts, e.g., in +scripts or CI, pass `--non-interactive` (or `-n`). The legacy `-y` flag remains +supported as an alias. ```bash # Login to Seam @@ -52,6 +53,9 @@ seam connect-webviews create # List devices in your workspace seam devices list +# List devices without being prompted for anything +seam devices list --non-interactive + MY_DOOR=$(seam devices get --name "Front Door" --id-only) # Unlock a lock diff --git a/src/bin/cli.ts b/src/bin/cli.ts index ec58146a..d348e995 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -4,7 +4,7 @@ import { isDeepStrictEqual as isEqual } from 'node:util' import chalk from 'chalk' import commandLineUsage from 'command-line-usage' -import parseArgs, { type ParsedArgs } from 'minimist' +import type { ParsedArgs } from 'minimist' import prompts from 'prompts' import { getApiBlueprint } from 'lib/get-api-blueprint.js' @@ -18,6 +18,11 @@ 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 type { ContextHelpers } from 'lib/types.js' +import { + isInteractive, + nonInteractiveFlags, + parseCliArgs, +} from 'lib/util/cli-args.js' import { RequestSeamApi } from 'lib/util/request-seam-api.js' import { validateToken } from 'lib/validate-token.js' import seamapiCliVersion from 'lib/version.js' @@ -26,7 +31,7 @@ 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 ', + 'Every seam command is interactive and will prompt you for any missing required properties with helpful suggestions. To skip the prompts, pass -n ', }, { header: 'Options', @@ -37,6 +42,13 @@ const sections = [ alias: 'h', type: Boolean, }, + { + name: 'non-interactive', + description: + 'Do not prompt: take the first suggestion for any missing property. Alias of the legacy -y flag.', + alias: 'n', + type: Boolean, + }, ], }, { @@ -50,6 +62,10 @@ const sections = [ summary: 'Create a connect webview to connect devices.', }, { name: 'seam devices list', summary: 'List devices in your workspace.' }, + { + name: 'seam devices list {bold --non-interactive}', + summary: 'List devices without being prompted for anything.', + }, { name: 'seam locks unlock-door {bold --device-id} $MY_DOOR', summary: 'Unlock a lock.', @@ -117,22 +133,19 @@ async function cli(args: ParsedArgs) { const commandParams: Record = {} - /** - * Whether or not to auto-select first option - */ - const is_interactive = args['y'] !== true - const ctx: ContextHelpers = { blueprint, - is_interactive, + is_interactive: isInteractive(args), } for (const k in args) { if (k === '_') continue const v = args[k] delete args[k] - args[k.replace(/-/g, '_')] = v - commandParams[k.replace(/-/g, '_')] = v + const key = k.replace(/-/g, '_') + args[key] = v + if (nonInteractiveFlags.includes(key)) continue + commandParams[key] = v } const selectedCommand = await interactForCommandSelection(args._, ctx) @@ -257,7 +270,7 @@ const handleConnectWebviewResponse = async (connect_webview: any) => { } } -cli(parseArgs(process.argv.slice(2), { string: ['code'] })).catch((e) => { +cli(parseCliArgs(process.argv.slice(2))).catch((e) => { console.log(chalk.red(`CLI Error: ${e.toString()}\n${e.stack}`)) if (e.toString().includes('object Object')) { console.log(e) diff --git a/src/lib/util/cli-args.test.ts b/src/lib/util/cli-args.test.ts new file mode 100644 index 00000000..fd6832d2 --- /dev/null +++ b/src/lib/util/cli-args.test.ts @@ -0,0 +1,32 @@ +import type { ParsedArgs } from 'minimist' +import { expect, test } from 'vitest' + +import { isInteractive, parseCliArgs } from './cli-args.js' + +// The CLI normalizes argument keys before checking them. +const parse = (argv: string[]): ParsedArgs => { + const args = parseCliArgs(argv) + for (const k in args) { + if (k === '_') continue + args[k.toLowerCase().replace(/-/g, '_')] = args[k] + } + return args +} + +test('isInteractive: interactive by default', () => { + expect(isInteractive(parse(['devices', 'list']))).toBe(true) +}) + +test('isInteractive: --non-interactive and its aliases opt out', () => { + expect(isInteractive(parse(['devices', 'list', '--non-interactive']))).toBe( + false, + ) + expect(isInteractive(parse(['devices', 'list', '-n']))).toBe(false) + expect(isInteractive(parse(['devices', 'list', '-y']))).toBe(false) +}) + +test('parseCliArgs: --non-interactive does not consume the next argument', () => { + const args = parse(['devices', 'get', '-n', '--device-id', 'foo']) + expect(args['device_id']).toBe('foo') + expect(args._).toEqual(['devices', 'get']) +}) diff --git a/src/lib/util/cli-args.ts b/src/lib/util/cli-args.ts new file mode 100644 index 00000000..a0595ab6 --- /dev/null +++ b/src/lib/util/cli-args.ts @@ -0,0 +1,22 @@ +import parseArgs, { type ParsedArgs } from 'minimist' + +/** + * Argument keys that disable interactive prompts. + * + * `-y` is the original spelling and is kept as an alias of `--non-interactive`. + */ +export const nonInteractiveFlags = ['non_interactive', 'n', 'y'] + +export const parseCliArgs = (argv: string[]): ParsedArgs => + parseArgs(argv, { + string: ['code'], + boolean: ['non-interactive', 'y'], + alias: { 'non-interactive': 'n' }, + }) + +/** + * Whether or not to prompt for missing parameters, + * as opposed to auto-selecting the first option. + */ +export const isInteractive = (args: ParsedArgs): boolean => + !nonInteractiveFlags.some((flag) => args[flag] === true) From 131b45de941924e7ec291d97cb474e69bb3d6f97 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 17:07:27 +0000 Subject: [PATCH 2/4] fix: Make --non-interactive distinct from -y -y skips the parameter review prompt when everything required was already given, but still prompts for whatever is missing. --non-interactive now never prompts: an incomplete command or a missing required parameter exits 1 with a message naming what is missing. It also skips the optional webview and action attempt poll prompts. Give -y the long form --yes so it can be documented as its own flag. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TxQCkfVypobQVA7DpF4jvx --- README.md | 20 ++++++-- src/bin/cli.ts | 48 ++++++++++++++----- src/lib/interact-for-blueprint-object.test.ts | 48 +++++++++++++++++++ src/lib/interact-for-blueprint-object.ts | 20 +++++++- .../interact-for-command-selection.test.ts | 39 +++++++++++++++ src/lib/interact-for-command-selection.ts | 21 ++++++++ src/lib/types.ts | 3 +- src/lib/util/cli-args.test.ts | 35 ++++++++++---- src/lib/util/cli-args.ts | 44 +++++++++++++---- 9 files changed, 242 insertions(+), 36 deletions(-) create mode 100644 src/lib/interact-for-blueprint-object.test.ts create mode 100644 src/lib/interact-for-command-selection.test.ts diff --git a/README.md b/README.md index 854bf699..9452dd78 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ A command line interface (CLI) for interacting with the Seam API. Every command is interactive: the CLI prompts for any missing required parameter with suggestions pulled from your workspace. Pass `--non-interactive` -(or `-n`) to take the first suggestion instead of being asked. +(or `-n`) to never be prompted: the command fails instead. ## Installation @@ -33,9 +33,16 @@ $ paru -S seam-bin ## Usage Every `seam` command is interactive and will prompt you for any missing -required properties with helpful suggestions. To skip the prompts, e.g., in -scripts or CI, pass `--non-interactive` (or `-n`). The legacy `-y` flag remains -supported as an alias. +required properties with helpful suggestions. + +For scripts and CI, pass `--non-interactive` (or `-n`) to never be prompted. +The command must then be complete: if the command itself is ambiguous, or any +required property is missing, the CLI exits with an error naming what is +missing instead of asking for it. + +Pass `--yes` (or `-y`) to only skip the prompt to review properties before the +API call is made. Unlike `--non-interactive`, anything still missing is +prompted for. ```bash # Login to Seam @@ -53,9 +60,12 @@ seam connect-webviews create # List devices in your workspace seam devices list -# List devices without being prompted for anything +# List devices, failing instead of prompting seam devices list --non-interactive +# Fails with: Missing required parameter for /locks/unlock_door: --device-id +seam locks unlock-door --non-interactive + MY_DOOR=$(seam devices get --name "Front Door" --id-only) # Unlock a lock diff --git a/src/bin/cli.ts b/src/bin/cli.ts index d348e995..f1c9424b 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -19,8 +19,10 @@ import { interactForUseRemoteApiDefs } from 'lib/interact-for-use-remote-api-def import { interactForWorkspaceId } from 'lib/interact-for-workspace-id.js' import type { ContextHelpers } from 'lib/types.js' import { - isInteractive, - nonInteractiveFlags, + getInteractivity, + type Interactivity, + interactivityFlags, + NonInteractiveError, parseCliArgs, } from 'lib/util/cli-args.js' import { RequestSeamApi } from 'lib/util/request-seam-api.js' @@ -31,7 +33,7 @@ const sections = [ { header: 'Seam CLI', content: - 'Every seam command is interactive and will prompt you for any missing required properties with helpful suggestions. To skip the prompts, pass -n ', + 'Every seam command is interactive and will prompt you for any missing required properties with helpful suggestions. To never be prompted, pass -n ', }, { header: 'Options', @@ -45,10 +47,17 @@ const sections = [ { name: 'non-interactive', description: - 'Do not prompt: take the first suggestion for any missing property. Alias of the legacy -y flag.', + 'Never prompt: exit with an error if the command or any required property is missing.', alias: 'n', type: Boolean, }, + { + name: 'yes', + description: + 'Do not prompt to review properties when every required property was already given.', + alias: 'y', + type: Boolean, + }, ], }, { @@ -64,7 +73,7 @@ const sections = [ { name: 'seam devices list', summary: 'List devices in your workspace.' }, { name: 'seam devices list {bold --non-interactive}', - summary: 'List devices without being prompted for anything.', + summary: 'List devices, failing instead of prompting.', }, { name: 'seam locks unlock-door {bold --device-id} $MY_DOOR', @@ -135,7 +144,7 @@ async function cli(args: ParsedArgs) { const ctx: ContextHelpers = { blueprint, - is_interactive: isInteractive(args), + interactivity: getInteractivity(args), } for (const k in args) { @@ -144,7 +153,7 @@ async function cli(args: ParsedArgs) { delete args[k] const key = k.replace(/-/g, '_') args[key] = v - if (nonInteractiveFlags.includes(key)) continue + if (interactivityFlags.includes(key)) continue commandParams[key] = v } @@ -245,18 +254,30 @@ async function cli(args: ParsedArgs) { }) if (response.data?.connect_webview) { - await handleConnectWebviewResponse(response.data.connect_webview) + await handleConnectWebviewResponse( + response.data.connect_webview, + ctx.interactivity, + ) } - if (response.data?.action_attempt) { + if ( + response.data?.action_attempt && + ctx.interactivity !== 'non-interactive' + ) { interactForActionAttemptPoll(response.data.action_attempt) } } -const handleConnectWebviewResponse = async (connect_webview: any) => { +const handleConnectWebviewResponse = async ( + connect_webview: any, + interactivity: Interactivity, +) => { const url = connect_webview.url - if (process.env['INSIDE_WEB_BROWSER'] !== '1') { + if ( + interactivity !== 'non-interactive' && + process.env['INSIDE_WEB_BROWSER'] !== '1' + ) { const { action } = await prompts({ type: 'confirm', name: 'action', @@ -271,6 +292,11 @@ const handleConnectWebviewResponse = async (connect_webview: any) => { } cli(parseCliArgs(process.argv.slice(2))).catch((e) => { + if (e instanceof NonInteractiveError) { + console.log(chalk.red(e.message)) + process.exit(1) + } + console.log(chalk.red(`CLI Error: ${e.toString()}\n${e.stack}`)) if (e.toString().includes('object Object')) { console.log(e) diff --git a/src/lib/interact-for-blueprint-object.test.ts b/src/lib/interact-for-blueprint-object.test.ts new file mode 100644 index 00000000..7d8bd07d --- /dev/null +++ b/src/lib/interact-for-blueprint-object.test.ts @@ -0,0 +1,48 @@ +import type { Parameter } from '@seamapi/blueprint' +import { expect, test } from 'vitest' + +import { interactForBlueprintObject } from './interact-for-blueprint-object.js' +import type { ContextHelpers } from './types.js' + +const parameters = [ + { name: 'device_id', isRequired: true, format: 'id' }, + { name: 'name', isRequired: false, format: 'string' }, +] as unknown as Parameter[] + +const ctx = (interactivity: ContextHelpers['interactivity']): ContextHelpers => + ({ interactivity, blueprint: {} }) as unknown as ContextHelpers + +const args = (params: Record) => ({ + command: ['devices', 'get'], + parameters, + params, +}) + +test('interactForBlueprintObject: submits given parameters when non-interactive', async () => { + await expect( + interactForBlueprintObject( + args({ device_id: 'device1' }), + ctx('non-interactive'), + ), + ).resolves.toEqual({ device_id: 'device1' }) +}) + +test('interactForBlueprintObject: submits given parameters with -y', async () => { + await expect( + interactForBlueprintObject( + args({ device_id: 'device1' }), + ctx('auto-submit'), + ), + ).resolves.toEqual({ device_id: 'device1' }) +}) + +test('interactForBlueprintObject: rejects missing required parameters when non-interactive', async () => { + await expect( + interactForBlueprintObject( + args({ name: 'Front Door' }), + ctx('non-interactive'), + ), + ).rejects.toThrowError( + 'Missing required parameter for /devices/get: --device-id', + ) +}) diff --git a/src/lib/interact-for-blueprint-object.ts b/src/lib/interact-for-blueprint-object.ts index e9f5ddd6..1d198af7 100644 --- a/src/lib/interact-for-blueprint-object.ts +++ b/src/lib/interact-for-blueprint-object.ts @@ -12,6 +12,7 @@ import { interactForDevice } from './interact-for-device.js' import { interactForTimestamp } from './interact-for-timestamp.js' import { interactForUserIdentity } from './interact-for-user-identity.js' import type { ContextHelpers } from './types.js' +import { NonInteractiveError, toArgName } from './util/cli-args.js' import { ellipsis } from './util/ellipsis.js' const ergonomicPropOrder = [ @@ -47,12 +48,28 @@ export const interactForBlueprintObject = async ( const haveAllRequiredParams = required.every((k) => args.params[k]) + const cmdPath = `/${args.command.join('/').replace(/-/g, '_')}` + const should_auto_submit = - !ctx.is_interactive && haveAllRequiredParams && !args.isSubProperty + ctx.interactivity !== 'interactive' && + haveAllRequiredParams && + !args.isSubProperty if (should_auto_submit) { return args.params } + if (ctx.interactivity === 'non-interactive') { + const missing = required.filter((k) => !args.params[k]) + const target = args.isSubProperty ? `"${args.subPropertyPath}"` : cmdPath + throw new NonInteractiveError( + missing.length > 0 + ? `Missing required ${ + missing.length === 1 ? 'parameter' : 'parameters' + } for ${target}: ${missing.map(toArgName).join(' ')}` + : `Cannot prompt for ${target} in non-interactive mode`, + ) + } + const propSortScore = (prop: string) => { if (required.includes(prop)) return 100 - ergonomicPropOrder.indexOf(prop) if (args.params[prop] !== undefined) { @@ -61,7 +78,6 @@ export const interactForBlueprintObject = async ( return ergonomicPropOrder.indexOf(prop) } - const cmdPath = `/${args.command.join('/').replace(/-/g, '_')}` const parameterSelectionMessage = args.isSubProperty ? `Editing "${args.subPropertyPath}"` : `[${cmdPath}] Parameters` diff --git a/src/lib/interact-for-command-selection.test.ts b/src/lib/interact-for-command-selection.test.ts new file mode 100644 index 00000000..479339c1 --- /dev/null +++ b/src/lib/interact-for-command-selection.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from 'vitest' + +import { interactForCommandSelection } from './interact-for-command-selection.js' +import type { ContextHelpers } from './types.js' + +const ctx = { + interactivity: 'non-interactive', + blueprint: { + routes: [ + { + endpoints: [ + { path: '/devices/get' }, + { path: '/devices/list' }, + { path: '/devices/unmanaged/list' }, + ], + }, + ], + }, +} as unknown as ContextHelpers + +test('interactForCommandSelection: resolves a complete command', async () => { + await expect( + interactForCommandSelection(['devices', 'list'], ctx), + ).resolves.toEqual(['devices', 'list']) +}) + +test('interactForCommandSelection: rejects an incomplete command when non-interactive', async () => { + await expect( + interactForCommandSelection(['devices'], ctx), + ).rejects.toThrowError( + 'Incomplete command "seam devices": expected one of list, get, unmanaged', + ) +}) + +test('interactForCommandSelection: rejects a missing command when non-interactive', async () => { + await expect(interactForCommandSelection([], ctx)).rejects.toThrowError( + /^Missing command: expected one of /, + ) +}) diff --git a/src/lib/interact-for-command-selection.ts b/src/lib/interact-for-command-selection.ts index 3eef904a..a21baa70 100644 --- a/src/lib/interact-for-command-selection.ts +++ b/src/lib/interact-for-command-selection.ts @@ -3,6 +3,7 @@ import { isDeepStrictEqual as isEqual } from 'node:util' import prompts from 'prompts' import type { ContextHelpers } from './types.js' +import { NonInteractiveError } from './util/cli-args.js' const uniqBy = (items: T[], keyOf: (item: T) => unknown): T[] => { const seen = new Set() @@ -64,6 +65,26 @@ export async function interactForCommandSelection( return commandPath } + if (helpers.interactivity === 'non-interactive') { + // The command path is itself a command, so call it directly rather than + // prompting to select one of its sub-commands. + if (possibleCommands.some((cmd) => cmd.length === commandPath.length)) { + return commandPath + } + + const subcommands = possibleCommands + .map((cmd) => cmd[commandPath.length]) + .filter((subcommand) => subcommand != null) + .sort(ergonomicSort) + throw new NonInteractiveError( + `${ + commandPath.length === 0 + ? 'Missing command' + : `Incomplete command "seam ${commandPath.join(' ')}"` + }: expected one of ${subcommands.join(', ')}`, + ) + } + // Add dynamic 'back' command for sub-commands to allow returning // to previous level. if (commandPath.length > 0) { diff --git a/src/lib/types.ts b/src/lib/types.ts index 62b53774..64a8d95c 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,6 +1,7 @@ import type { ApiBlueprint } from './get-api-blueprint.js' +import type { Interactivity } from './util/cli-args.js' export interface ContextHelpers { blueprint: ApiBlueprint - is_interactive: boolean + interactivity: Interactivity } diff --git a/src/lib/util/cli-args.test.ts b/src/lib/util/cli-args.test.ts index fd6832d2..4030f194 100644 --- a/src/lib/util/cli-args.test.ts +++ b/src/lib/util/cli-args.test.ts @@ -1,7 +1,7 @@ import type { ParsedArgs } from 'minimist' import { expect, test } from 'vitest' -import { isInteractive, parseCliArgs } from './cli-args.js' +import { getInteractivity, parseCliArgs, toArgName } from './cli-args.js' // The CLI normalizes argument keys before checking them. const parse = (argv: string[]): ParsedArgs => { @@ -13,16 +13,30 @@ const parse = (argv: string[]): ParsedArgs => { return args } -test('isInteractive: interactive by default', () => { - expect(isInteractive(parse(['devices', 'list']))).toBe(true) +test('getInteractivity: interactive by default', () => { + expect(getInteractivity(parse(['devices', 'list']))).toBe('interactive') }) -test('isInteractive: --non-interactive and its aliases opt out', () => { - expect(isInteractive(parse(['devices', 'list', '--non-interactive']))).toBe( - false, +test('getInteractivity: --non-interactive and -n never prompt', () => { + expect( + getInteractivity(parse(['devices', 'list', '--non-interactive'])), + ).toBe('non-interactive') + expect(getInteractivity(parse(['devices', 'list', '-n']))).toBe( + 'non-interactive', + ) +}) + +test('getInteractivity: --yes and -y only skip the parameter prompt', () => { + expect(getInteractivity(parse(['devices', 'list', '--yes']))).toBe( + 'auto-submit', + ) + expect(getInteractivity(parse(['devices', 'list', '-y']))).toBe('auto-submit') +}) + +test('getInteractivity: --non-interactive wins over -y', () => { + expect(getInteractivity(parse(['devices', 'list', '-y', '-n']))).toBe( + 'non-interactive', ) - expect(isInteractive(parse(['devices', 'list', '-n']))).toBe(false) - expect(isInteractive(parse(['devices', 'list', '-y']))).toBe(false) }) test('parseCliArgs: --non-interactive does not consume the next argument', () => { @@ -30,3 +44,8 @@ test('parseCliArgs: --non-interactive does not consume the next argument', () => expect(args['device_id']).toBe('foo') expect(args._).toEqual(['devices', 'get']) }) + +test('toArgName: renders a parameter as its argument', () => { + expect(toArgName('device_id')).toBe('--device-id') + expect(toArgName('code')).toBe('--code') +}) diff --git a/src/lib/util/cli-args.ts b/src/lib/util/cli-args.ts index a0595ab6..23aa2b81 100644 --- a/src/lib/util/cli-args.ts +++ b/src/lib/util/cli-args.ts @@ -1,22 +1,48 @@ import parseArgs, { type ParsedArgs } from 'minimist' /** - * Argument keys that disable interactive prompts. + * How the CLI should behave when it needs input that was not given as an + * argument. * - * `-y` is the original spelling and is kept as an alias of `--non-interactive`. + * - `interactive`: prompt for it. This is the default. + * - `auto-submit`: skip the parameter prompt when everything required + * was already given, otherwise prompt. Selected with `--yes` or `-y`. + * - `non-interactive`: never prompt: missing input is an error. + * Selected with `--non-interactive` or `-n`. */ -export const nonInteractiveFlags = ['non_interactive', 'n', 'y'] +export type Interactivity = 'interactive' | 'auto-submit' | 'non-interactive' + +/** + * Argument keys that affect interactivity + * and are therefore not command parameters. + */ +export const interactivityFlags = ['non_interactive', 'n', 'yes', 'y'] + +/** + * Thrown when the CLI needs input it cannot prompt for. + */ +export class NonInteractiveError extends Error { + override name = 'NonInteractiveError' +} export const parseCliArgs = (argv: string[]): ParsedArgs => parseArgs(argv, { string: ['code'], - boolean: ['non-interactive', 'y'], - alias: { 'non-interactive': 'n' }, + boolean: ['non-interactive', 'yes'], + alias: { 'non-interactive': 'n', yes: 'y' }, }) +export const getInteractivity = (args: ParsedArgs): Interactivity => { + if (args['non_interactive'] === true || args['n'] === true) { + return 'non-interactive' + } + if (args['yes'] === true || args['y'] === true) return 'auto-submit' + return 'interactive' +} + /** - * Whether or not to prompt for missing parameters, - * as opposed to auto-selecting the first option. + * Render a parameter name as the argument used to set it, + * e.g., `device_id` as `--device-id`. */ -export const isInteractive = (args: ParsedArgs): boolean => - !nonInteractiveFlags.some((flag) => args[flag] === true) +export const toArgName = (parameterName: string): string => + `--${parameterName.replace(/_/g, '-')}` From c9d43615ac477ee36239482cfc5dab0184736fdd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 18:46:48 +0000 Subject: [PATCH 3/4] refactor: Make -y the short flag for --non-interactive -y differed from --non-interactive only by prompting instead of failing when something was missing, which is not worth a separate flag. Collapse them: -y is now simply the short flag for --non-interactive, and its --yes long form is dropped. This leaves -n unused so it can become --dry-run. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TxQCkfVypobQVA7DpF4jvx --- README.md | 8 +--- src/bin/cli.ts | 32 +++++----------- src/lib/interact-for-blueprint-object.test.ts | 22 +++-------- src/lib/interact-for-blueprint-object.ts | 13 +++---- .../interact-for-command-selection.test.ts | 2 +- src/lib/interact-for-command-selection.ts | 2 +- src/lib/types.ts | 3 +- src/lib/util/cli-args.test.ts | 31 +++++----------- src/lib/util/cli-args.ts | 37 +++++++------------ 9 files changed, 49 insertions(+), 101 deletions(-) diff --git a/README.md b/README.md index 9452dd78..fa8f42af 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ A command line interface (CLI) for interacting with the Seam API. Every command is interactive: the CLI prompts for any missing required parameter with suggestions pulled from your workspace. Pass `--non-interactive` -(or `-n`) to never be prompted: the command fails instead. +(or `-y`) to never be prompted: the command fails instead. ## Installation @@ -35,15 +35,11 @@ $ paru -S seam-bin Every `seam` command is interactive and will prompt you for any missing required properties with helpful suggestions. -For scripts and CI, pass `--non-interactive` (or `-n`) to never be prompted. +For scripts and CI, pass `--non-interactive` (or `-y`) to never be prompted. The command must then be complete: if the command itself is ambiguous, or any required property is missing, the CLI exits with an error naming what is missing instead of asking for it. -Pass `--yes` (or `-y`) to only skip the prompt to review properties before the -API call is made. Unlike `--non-interactive`, anything still missing is -prompted for. - ```bash # Login to Seam seam login diff --git a/src/bin/cli.ts b/src/bin/cli.ts index f1c9424b..01cf0bc2 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -19,10 +19,9 @@ import { interactForUseRemoteApiDefs } from 'lib/interact-for-use-remote-api-def import { interactForWorkspaceId } from 'lib/interact-for-workspace-id.js' import type { ContextHelpers } from 'lib/types.js' import { - getInteractivity, - type Interactivity, - interactivityFlags, + isInteractive, NonInteractiveError, + nonInteractiveFlags, parseCliArgs, } from 'lib/util/cli-args.js' import { RequestSeamApi } from 'lib/util/request-seam-api.js' @@ -33,7 +32,7 @@ const sections = [ { header: 'Seam CLI', content: - 'Every seam command is interactive and will prompt you for any missing required properties with helpful suggestions. To never be prompted, pass -n ', + 'Every seam command is interactive and will prompt you for any missing required properties with helpful suggestions. To never be prompted, pass -y ', }, { header: 'Options', @@ -48,13 +47,6 @@ const sections = [ name: 'non-interactive', description: 'Never prompt: exit with an error if the command or any required property is missing.', - alias: 'n', - type: Boolean, - }, - { - name: 'yes', - description: - 'Do not prompt to review properties when every required property was already given.', alias: 'y', type: Boolean, }, @@ -144,7 +136,7 @@ async function cli(args: ParsedArgs) { const ctx: ContextHelpers = { blueprint, - interactivity: getInteractivity(args), + is_interactive: isInteractive(args), } for (const k in args) { @@ -153,7 +145,7 @@ async function cli(args: ParsedArgs) { delete args[k] const key = k.replace(/-/g, '_') args[key] = v - if (interactivityFlags.includes(key)) continue + if (nonInteractiveFlags.includes(key)) continue commandParams[key] = v } @@ -256,28 +248,22 @@ async function cli(args: ParsedArgs) { if (response.data?.connect_webview) { await handleConnectWebviewResponse( response.data.connect_webview, - ctx.interactivity, + ctx.is_interactive, ) } - if ( - response.data?.action_attempt && - ctx.interactivity !== 'non-interactive' - ) { + if (response.data?.action_attempt && ctx.is_interactive) { interactForActionAttemptPoll(response.data.action_attempt) } } const handleConnectWebviewResponse = async ( connect_webview: any, - interactivity: Interactivity, + is_interactive: boolean, ) => { const url = connect_webview.url - if ( - interactivity !== 'non-interactive' && - process.env['INSIDE_WEB_BROWSER'] !== '1' - ) { + if (is_interactive && process.env['INSIDE_WEB_BROWSER'] !== '1') { const { action } = await prompts({ type: 'confirm', name: 'action', diff --git a/src/lib/interact-for-blueprint-object.test.ts b/src/lib/interact-for-blueprint-object.test.ts index 7d8bd07d..78c91838 100644 --- a/src/lib/interact-for-blueprint-object.test.ts +++ b/src/lib/interact-for-blueprint-object.test.ts @@ -9,8 +9,10 @@ const parameters = [ { name: 'name', isRequired: false, format: 'string' }, ] as unknown as Parameter[] -const ctx = (interactivity: ContextHelpers['interactivity']): ContextHelpers => - ({ interactivity, blueprint: {} }) as unknown as ContextHelpers +const nonInteractiveCtx = { + is_interactive: false, + blueprint: {}, +} as unknown as ContextHelpers const args = (params: Record) => ({ command: ['devices', 'get'], @@ -22,26 +24,14 @@ test('interactForBlueprintObject: submits given parameters when non-interactive' await expect( interactForBlueprintObject( args({ device_id: 'device1' }), - ctx('non-interactive'), - ), - ).resolves.toEqual({ device_id: 'device1' }) -}) - -test('interactForBlueprintObject: submits given parameters with -y', async () => { - await expect( - interactForBlueprintObject( - args({ device_id: 'device1' }), - ctx('auto-submit'), + nonInteractiveCtx, ), ).resolves.toEqual({ device_id: 'device1' }) }) test('interactForBlueprintObject: rejects missing required parameters when non-interactive', async () => { await expect( - interactForBlueprintObject( - args({ name: 'Front Door' }), - ctx('non-interactive'), - ), + interactForBlueprintObject(args({ name: 'Front Door' }), nonInteractiveCtx), ).rejects.toThrowError( 'Missing required parameter for /devices/get: --device-id', ) diff --git a/src/lib/interact-for-blueprint-object.ts b/src/lib/interact-for-blueprint-object.ts index 1d198af7..c4853220 100644 --- a/src/lib/interact-for-blueprint-object.ts +++ b/src/lib/interact-for-blueprint-object.ts @@ -50,15 +50,12 @@ export const interactForBlueprintObject = async ( const cmdPath = `/${args.command.join('/').replace(/-/g, '_')}` - const should_auto_submit = - ctx.interactivity !== 'interactive' && - haveAllRequiredParams && - !args.isSubProperty - if (should_auto_submit) { - return args.params - } + if (!ctx.is_interactive) { + const should_auto_submit = haveAllRequiredParams && !args.isSubProperty + if (should_auto_submit) { + return args.params + } - if (ctx.interactivity === 'non-interactive') { const missing = required.filter((k) => !args.params[k]) const target = args.isSubProperty ? `"${args.subPropertyPath}"` : cmdPath throw new NonInteractiveError( diff --git a/src/lib/interact-for-command-selection.test.ts b/src/lib/interact-for-command-selection.test.ts index 479339c1..f0926661 100644 --- a/src/lib/interact-for-command-selection.test.ts +++ b/src/lib/interact-for-command-selection.test.ts @@ -4,7 +4,7 @@ import { interactForCommandSelection } from './interact-for-command-selection.js import type { ContextHelpers } from './types.js' const ctx = { - interactivity: 'non-interactive', + is_interactive: false, blueprint: { routes: [ { diff --git a/src/lib/interact-for-command-selection.ts b/src/lib/interact-for-command-selection.ts index a21baa70..499a47ba 100644 --- a/src/lib/interact-for-command-selection.ts +++ b/src/lib/interact-for-command-selection.ts @@ -65,7 +65,7 @@ export async function interactForCommandSelection( return commandPath } - if (helpers.interactivity === 'non-interactive') { + if (!helpers.is_interactive) { // The command path is itself a command, so call it directly rather than // prompting to select one of its sub-commands. if (possibleCommands.some((cmd) => cmd.length === commandPath.length)) { diff --git a/src/lib/types.ts b/src/lib/types.ts index 64a8d95c..62b53774 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,7 +1,6 @@ import type { ApiBlueprint } from './get-api-blueprint.js' -import type { Interactivity } from './util/cli-args.js' export interface ContextHelpers { blueprint: ApiBlueprint - interactivity: Interactivity + is_interactive: boolean } diff --git a/src/lib/util/cli-args.test.ts b/src/lib/util/cli-args.test.ts index 4030f194..09f1cd7d 100644 --- a/src/lib/util/cli-args.test.ts +++ b/src/lib/util/cli-args.test.ts @@ -1,7 +1,7 @@ import type { ParsedArgs } from 'minimist' import { expect, test } from 'vitest' -import { getInteractivity, parseCliArgs, toArgName } from './cli-args.js' +import { isInteractive, parseCliArgs, toArgName } from './cli-args.js' // The CLI normalizes argument keys before checking them. const parse = (argv: string[]): ParsedArgs => { @@ -13,34 +13,23 @@ const parse = (argv: string[]): ParsedArgs => { return args } -test('getInteractivity: interactive by default', () => { - expect(getInteractivity(parse(['devices', 'list']))).toBe('interactive') +test('isInteractive: interactive by default', () => { + expect(isInteractive(parse(['devices', 'list']))).toBe(true) }) -test('getInteractivity: --non-interactive and -n never prompt', () => { - expect( - getInteractivity(parse(['devices', 'list', '--non-interactive'])), - ).toBe('non-interactive') - expect(getInteractivity(parse(['devices', 'list', '-n']))).toBe( - 'non-interactive', +test('isInteractive: --non-interactive and -y never prompt', () => { + expect(isInteractive(parse(['devices', 'list', '--non-interactive']))).toBe( + false, ) + expect(isInteractive(parse(['devices', 'list', '-y']))).toBe(false) }) -test('getInteractivity: --yes and -y only skip the parameter prompt', () => { - expect(getInteractivity(parse(['devices', 'list', '--yes']))).toBe( - 'auto-submit', - ) - expect(getInteractivity(parse(['devices', 'list', '-y']))).toBe('auto-submit') -}) - -test('getInteractivity: --non-interactive wins over -y', () => { - expect(getInteractivity(parse(['devices', 'list', '-y', '-n']))).toBe( - 'non-interactive', - ) +test('isInteractive: -n is reserved and does not affect interactivity', () => { + expect(isInteractive(parse(['devices', 'list', '-n']))).toBe(true) }) test('parseCliArgs: --non-interactive does not consume the next argument', () => { - const args = parse(['devices', 'get', '-n', '--device-id', 'foo']) + const args = parse(['devices', 'get', '-y', '--device-id', 'foo']) expect(args['device_id']).toBe('foo') expect(args._).toEqual(['devices', 'get']) }) diff --git a/src/lib/util/cli-args.ts b/src/lib/util/cli-args.ts index 23aa2b81..d32cbb39 100644 --- a/src/lib/util/cli-args.ts +++ b/src/lib/util/cli-args.ts @@ -1,22 +1,10 @@ import parseArgs, { type ParsedArgs } from 'minimist' /** - * How the CLI should behave when it needs input that was not given as an - * argument. - * - * - `interactive`: prompt for it. This is the default. - * - `auto-submit`: skip the parameter prompt when everything required - * was already given, otherwise prompt. Selected with `--yes` or `-y`. - * - `non-interactive`: never prompt: missing input is an error. - * Selected with `--non-interactive` or `-n`. - */ -export type Interactivity = 'interactive' | 'auto-submit' | 'non-interactive' - -/** - * Argument keys that affect interactivity + * Argument keys that disable interactive prompts * and are therefore not command parameters. */ -export const interactivityFlags = ['non_interactive', 'n', 'yes', 'y'] +export const nonInteractiveFlags = ['non_interactive', 'y'] /** * Thrown when the CLI needs input it cannot prompt for. @@ -28,17 +16,20 @@ export class NonInteractiveError extends Error { export const parseCliArgs = (argv: string[]): ParsedArgs => parseArgs(argv, { string: ['code'], - boolean: ['non-interactive', 'yes'], - alias: { 'non-interactive': 'n', yes: 'y' }, + boolean: ['non-interactive'], + // Deliberately not aliased to -n, which is reserved for a future + // --dry-run flag. + alias: { 'non-interactive': 'y' }, }) -export const getInteractivity = (args: ParsedArgs): Interactivity => { - if (args['non_interactive'] === true || args['n'] === true) { - return 'non-interactive' - } - if (args['yes'] === true || args['y'] === true) return 'auto-submit' - return 'interactive' -} +/** + * Whether or not the CLI may prompt for input. + * + * When false, the command must be given in full: + * anything missing is an error instead of a prompt. + */ +export const isInteractive = (args: ParsedArgs): boolean => + !nonInteractiveFlags.some((flag) => args[flag] === true) /** * Render a parameter name as the argument used to set it, From 6724982e0086f71e7d676ca46f718647ec9349f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 18:54:21 +0000 Subject: [PATCH 4/4] feat: Run commands directly and add --interactive to review first A command given every required property now makes its request instead of prompting to review properties first, which is what -y used to be for. Add --interactive (-i) to force that review prompt, prefilled with the given arguments, for adding optional properties or checking a request before making it. --non-interactive (-y) keeps failing instead of prompting, and now also fails instead of prompting for login, workspace, and server selection. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TxQCkfVypobQVA7DpF4jvx --- README.md | 19 ++++-- src/bin/cli.ts | 55 ++++++++++++++--- src/lib/interact-for-blueprint-object.test.ts | 59 ++++++++++++++++--- src/lib/interact-for-blueprint-object.ts | 13 ++-- .../interact-for-command-selection.test.ts | 2 +- src/lib/interact-for-command-selection.ts | 2 +- src/lib/types.ts | 3 +- src/lib/util/cli-args.test.ts | 37 ++++++++---- src/lib/util/cli-args.ts | 41 +++++++++---- 9 files changed, 178 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index fa8f42af..78407a99 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,9 @@ A command line interface (CLI) for interacting with the Seam API. ## Description -Every command is interactive: the CLI prompts for any missing required -parameter with suggestions pulled from your workspace. Pass `--non-interactive` -(or `-y`) to never be prompted: the command fails instead. +Commands run as soon as every required parameter is given. Anything missing is +prompted for, with suggestions pulled from your workspace. Pass +`--non-interactive` (or `-y`) to never be prompted: the command fails instead. ## Installation @@ -32,8 +32,14 @@ $ paru -S seam-bin ## Usage -Every `seam` command is interactive and will prompt you for any missing -required properties with helpful suggestions. +Every `seam` command makes its request as soon as every required property is +given. When something is missing, the CLI prompts you for it with helpful +suggestions. + +Pass `--interactive` (or `-i`) to always be prompted to review and edit +properties before the request is made. The prompt is prefilled with whatever +you passed as arguments, so this is the way to add optional properties, or to +check a request before making it. For scripts and CI, pass `--non-interactive` (or `-y`) to never be prompted. The command must then be complete: if the command itself is ambiguous, or any @@ -56,6 +62,9 @@ seam connect-webviews create # List devices in your workspace seam devices list +# Review and edit filters before listing devices +seam devices list --interactive + # List devices, failing instead of prompting seam devices list --non-interactive diff --git a/src/bin/cli.ts b/src/bin/cli.ts index 01cf0bc2..e1e9d341 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -19,9 +19,10 @@ import { interactForUseRemoteApiDefs } from 'lib/interact-for-use-remote-api-def import { interactForWorkspaceId } from 'lib/interact-for-workspace-id.js' import type { ContextHelpers } from 'lib/types.js' import { - isInteractive, + getInteractivity, + type Interactivity, + interactivityFlags, NonInteractiveError, - nonInteractiveFlags, parseCliArgs, } from 'lib/util/cli-args.js' import { RequestSeamApi } from 'lib/util/request-seam-api.js' @@ -32,7 +33,7 @@ const sections = [ { header: 'Seam CLI', content: - 'Every seam command is interactive and will prompt you for any missing required properties with helpful suggestions. To never be prompted, pass -y ', + 'Every seam command runs as soon as every required property is given, and otherwise prompts you for what is missing with helpful suggestions. Pass -i to always review properties first, or -y to never be prompted. ', }, { header: 'Options', @@ -43,6 +44,13 @@ const sections = [ alias: 'h', type: Boolean, }, + { + name: 'interactive', + description: + 'Always prompt to review and edit properties, prefilled with the given arguments.', + alias: 'i', + type: Boolean, + }, { name: 'non-interactive', description: @@ -63,6 +71,10 @@ const sections = [ summary: 'Create a connect webview to connect devices.', }, { name: 'seam devices list', summary: 'List devices in your workspace.' }, + { + name: 'seam devices list {bold --interactive}', + summary: 'Review and edit filters before listing devices.', + }, { name: 'seam devices list {bold --non-interactive}', summary: 'List devices, failing instead of prompting.', @@ -136,16 +148,18 @@ async function cli(args: ParsedArgs) { const ctx: ContextHelpers = { blueprint, - is_interactive: isInteractive(args), + interactivity: getInteractivity(args), } + const isNonInteractive = ctx.interactivity === 'non-interactive' + for (const k in args) { if (k === '_') continue const v = args[k] delete args[k] const key = k.replace(/-/g, '_') args[key] = v - if (nonInteractiveFlags.includes(key)) continue + if (interactivityFlags.includes(key)) continue commandParams[key] = v } @@ -167,6 +181,11 @@ async function cli(args: ParsedArgs) { if (args['token'] || args['workspace_id'] || args['server']) { return } + if (isNonInteractive) { + throw new NonInteractiveError( + 'Missing required parameter for login: --token', + ) + } await interactForLogin() return } else if (isEqual(selectedCommand, ['logout'])) { @@ -177,9 +196,19 @@ async function cli(args: ParsedArgs) { console.log(config.path) return } else if (isEqual(selectedCommand, ['config', 'use-remote-api-defs'])) { + if (isNonInteractive) { + throw new NonInteractiveError( + 'Cannot select whether to use remote API definitions in non-interactive mode', + ) + } await interactForUseRemoteApiDefs() return } else if (isEqual(selectedCommand, ['select', 'workspace'])) { + if (isNonInteractive) { + throw new NonInteractiveError( + 'Cannot select a workspace in non-interactive mode: pass --workspace-id to "seam login"', + ) + } await interactForWorkspaceId() return } else if (isEqual(selectedCommand, ['events', 'list'])) { @@ -194,6 +223,11 @@ async function cli(args: ParsedArgs) { config.delete('current_workspace_id') return } + if (isNonInteractive) { + throw new NonInteractiveError( + 'Missing required parameter for select server: --server', + ) + } await interactForServerSelection() return } else if (isEqual(selectedCommand, ['health', 'get-health'])) { @@ -248,22 +282,25 @@ async function cli(args: ParsedArgs) { if (response.data?.connect_webview) { await handleConnectWebviewResponse( response.data.connect_webview, - ctx.is_interactive, + ctx.interactivity, ) } - if (response.data?.action_attempt && ctx.is_interactive) { + if (response.data?.action_attempt && !isNonInteractive) { interactForActionAttemptPoll(response.data.action_attempt) } } const handleConnectWebviewResponse = async ( connect_webview: any, - is_interactive: boolean, + interactivity: Interactivity, ) => { const url = connect_webview.url - if (is_interactive && process.env['INSIDE_WEB_BROWSER'] !== '1') { + if ( + interactivity !== 'non-interactive' && + process.env['INSIDE_WEB_BROWSER'] !== '1' + ) { const { action } = await prompts({ type: 'confirm', name: 'action', diff --git a/src/lib/interact-for-blueprint-object.test.ts b/src/lib/interact-for-blueprint-object.test.ts index 78c91838..fcbae014 100644 --- a/src/lib/interact-for-blueprint-object.test.ts +++ b/src/lib/interact-for-blueprint-object.test.ts @@ -1,18 +1,25 @@ import type { Parameter } from '@seamapi/blueprint' -import { expect, test } from 'vitest' +import prompts from 'prompts' +import { beforeEach, expect, test, vi } from 'vitest' import { interactForBlueprintObject } from './interact-for-blueprint-object.js' import type { ContextHelpers } from './types.js' +vi.mock('prompts', () => ({ + default: vi.fn(async () => ({ paramToEdit: 'done' })), +})) + +beforeEach(() => { + vi.mocked(prompts).mockClear() +}) + const parameters = [ { name: 'device_id', isRequired: true, format: 'id' }, { name: 'name', isRequired: false, format: 'string' }, ] as unknown as Parameter[] -const nonInteractiveCtx = { - is_interactive: false, - blueprint: {}, -} as unknown as ContextHelpers +const ctx = (interactivity: ContextHelpers['interactivity']): ContextHelpers => + ({ interactivity, blueprint: {} }) as unknown as ContextHelpers const args = (params: Record) => ({ command: ['devices', 'get'], @@ -20,19 +27,55 @@ const args = (params: Record) => ({ params, }) -test('interactForBlueprintObject: submits given parameters when non-interactive', async () => { +test('interactForBlueprintObject: submits without prompting once every required parameter is given', async () => { + await expect( + interactForBlueprintObject(args({ device_id: 'device1' }), ctx('auto')), + ).resolves.toEqual({ device_id: 'device1' }) + expect(prompts).not.toHaveBeenCalled() +}) + +test('interactForBlueprintObject: prompts to review given parameters when interactive', async () => { + await expect( + interactForBlueprintObject( + args({ device_id: 'device1' }), + ctx('interactive'), + ), + ).resolves.toEqual({ device_id: 'device1' }) + expect(prompts).toHaveBeenCalledTimes(1) +}) + +test('interactForBlueprintObject: prefills the prompt with the given parameters', async () => { + await interactForBlueprintObject( + args({ device_id: 'device1' }), + ctx('interactive'), + ) + + const { choices } = vi.mocked(prompts).mock.calls[0]?.[0] as { + choices: Array<{ value: string; description?: string }> + } + expect(choices.find(({ value }) => value === 'device_id')).toMatchObject({ + description: '[device1]', + }) +}) + +test('interactForBlueprintObject: submits without prompting when non-interactive', async () => { await expect( interactForBlueprintObject( args({ device_id: 'device1' }), - nonInteractiveCtx, + ctx('non-interactive'), ), ).resolves.toEqual({ device_id: 'device1' }) + expect(prompts).not.toHaveBeenCalled() }) test('interactForBlueprintObject: rejects missing required parameters when non-interactive', async () => { await expect( - interactForBlueprintObject(args({ name: 'Front Door' }), nonInteractiveCtx), + interactForBlueprintObject( + args({ name: 'Front Door' }), + ctx('non-interactive'), + ), ).rejects.toThrowError( 'Missing required parameter for /devices/get: --device-id', ) + expect(prompts).not.toHaveBeenCalled() }) diff --git a/src/lib/interact-for-blueprint-object.ts b/src/lib/interact-for-blueprint-object.ts index c4853220..1d198af7 100644 --- a/src/lib/interact-for-blueprint-object.ts +++ b/src/lib/interact-for-blueprint-object.ts @@ -50,12 +50,15 @@ export const interactForBlueprintObject = async ( const cmdPath = `/${args.command.join('/').replace(/-/g, '_')}` - if (!ctx.is_interactive) { - const should_auto_submit = haveAllRequiredParams && !args.isSubProperty - if (should_auto_submit) { - return args.params - } + const should_auto_submit = + ctx.interactivity !== 'interactive' && + haveAllRequiredParams && + !args.isSubProperty + if (should_auto_submit) { + return args.params + } + if (ctx.interactivity === 'non-interactive') { const missing = required.filter((k) => !args.params[k]) const target = args.isSubProperty ? `"${args.subPropertyPath}"` : cmdPath throw new NonInteractiveError( diff --git a/src/lib/interact-for-command-selection.test.ts b/src/lib/interact-for-command-selection.test.ts index f0926661..479339c1 100644 --- a/src/lib/interact-for-command-selection.test.ts +++ b/src/lib/interact-for-command-selection.test.ts @@ -4,7 +4,7 @@ import { interactForCommandSelection } from './interact-for-command-selection.js import type { ContextHelpers } from './types.js' const ctx = { - is_interactive: false, + interactivity: 'non-interactive', blueprint: { routes: [ { diff --git a/src/lib/interact-for-command-selection.ts b/src/lib/interact-for-command-selection.ts index 499a47ba..a21baa70 100644 --- a/src/lib/interact-for-command-selection.ts +++ b/src/lib/interact-for-command-selection.ts @@ -65,7 +65,7 @@ export async function interactForCommandSelection( return commandPath } - if (!helpers.is_interactive) { + if (helpers.interactivity === 'non-interactive') { // The command path is itself a command, so call it directly rather than // prompting to select one of its sub-commands. if (possibleCommands.some((cmd) => cmd.length === commandPath.length)) { diff --git a/src/lib/types.ts b/src/lib/types.ts index 62b53774..64a8d95c 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,6 +1,7 @@ import type { ApiBlueprint } from './get-api-blueprint.js' +import type { Interactivity } from './util/cli-args.js' export interface ContextHelpers { blueprint: ApiBlueprint - is_interactive: boolean + interactivity: Interactivity } diff --git a/src/lib/util/cli-args.test.ts b/src/lib/util/cli-args.test.ts index 09f1cd7d..462af8b3 100644 --- a/src/lib/util/cli-args.test.ts +++ b/src/lib/util/cli-args.test.ts @@ -1,7 +1,7 @@ import type { ParsedArgs } from 'minimist' import { expect, test } from 'vitest' -import { isInteractive, parseCliArgs, toArgName } from './cli-args.js' +import { getInteractivity, parseCliArgs, toArgName } from './cli-args.js' // The CLI normalizes argument keys before checking them. const parse = (argv: string[]): ParsedArgs => { @@ -13,23 +13,38 @@ const parse = (argv: string[]): ParsedArgs => { return args } -test('isInteractive: interactive by default', () => { - expect(isInteractive(parse(['devices', 'list']))).toBe(true) +test('getInteractivity: prompts only for what is missing by default', () => { + expect(getInteractivity(parse(['devices', 'list']))).toBe('auto') }) -test('isInteractive: --non-interactive and -y never prompt', () => { - expect(isInteractive(parse(['devices', 'list', '--non-interactive']))).toBe( - false, +test('getInteractivity: --interactive and -i always prompt', () => { + expect(getInteractivity(parse(['devices', 'list', '--interactive']))).toBe( + 'interactive', ) - expect(isInteractive(parse(['devices', 'list', '-y']))).toBe(false) + expect(getInteractivity(parse(['devices', 'list', '-i']))).toBe('interactive') }) -test('isInteractive: -n is reserved and does not affect interactivity', () => { - expect(isInteractive(parse(['devices', 'list', '-n']))).toBe(true) +test('getInteractivity: --non-interactive and -y never prompt', () => { + expect( + getInteractivity(parse(['devices', 'list', '--non-interactive'])), + ).toBe('non-interactive') + expect(getInteractivity(parse(['devices', 'list', '-y']))).toBe( + 'non-interactive', + ) +}) + +test('getInteractivity: --non-interactive wins over --interactive', () => { + expect(getInteractivity(parse(['devices', 'list', '-i', '-y']))).toBe( + 'non-interactive', + ) +}) + +test('getInteractivity: -n is reserved and does not affect interactivity', () => { + expect(getInteractivity(parse(['devices', 'list', '-n']))).toBe('auto') }) -test('parseCliArgs: --non-interactive does not consume the next argument', () => { - const args = parse(['devices', 'get', '-y', '--device-id', 'foo']) +test('parseCliArgs: interactivity flags do not consume the next argument', () => { + const args = parse(['devices', 'get', '-y', '-i', '--device-id', 'foo']) expect(args['device_id']).toBe('foo') expect(args._).toEqual(['devices', 'get']) }) diff --git a/src/lib/util/cli-args.ts b/src/lib/util/cli-args.ts index d32cbb39..0b79e200 100644 --- a/src/lib/util/cli-args.ts +++ b/src/lib/util/cli-args.ts @@ -1,10 +1,27 @@ import parseArgs, { type ParsedArgs } from 'minimist' /** - * Argument keys that disable interactive prompts + * How the CLI should behave when properties are not given as arguments. + * + * - `auto`: make the request as soon as every required property is given, + * otherwise prompt for what is missing. This is the default. + * - `interactive`: always prompt to review and edit properties, prefilled + * with the given arguments. Selected with `--interactive` or `-i`. + * - `non-interactive`: never prompt: anything missing is an error. + * Selected with `--non-interactive` or `-y`. + */ +export type Interactivity = 'auto' | 'interactive' | 'non-interactive' + +/** + * Argument keys that select the interactivity * and are therefore not command parameters. */ -export const nonInteractiveFlags = ['non_interactive', 'y'] +export const interactivityFlags: string[] = [ + 'non_interactive', + 'y', + 'interactive', + 'i', +] /** * Thrown when the CLI needs input it cannot prompt for. @@ -16,20 +33,20 @@ export class NonInteractiveError extends Error { export const parseCliArgs = (argv: string[]): ParsedArgs => parseArgs(argv, { string: ['code'], - boolean: ['non-interactive'], + boolean: ['non-interactive', 'interactive'], // Deliberately not aliased to -n, which is reserved for a future // --dry-run flag. - alias: { 'non-interactive': 'y' }, + alias: { 'non-interactive': 'y', interactive: 'i' }, }) -/** - * Whether or not the CLI may prompt for input. - * - * When false, the command must be given in full: - * anything missing is an error instead of a prompt. - */ -export const isInteractive = (args: ParsedArgs): boolean => - !nonInteractiveFlags.some((flag) => args[flag] === true) +export const getInteractivity = (args: ParsedArgs): Interactivity => { + // Prefer the flag that cannot hang when both are given. + if (args['non_interactive'] === true || args['y'] === true) { + return 'non-interactive' + } + if (args['interactive'] === true || args['i'] === true) return 'interactive' + return 'auto' +} /** * Render a parameter name as the argument used to set it,