From ee3a71dc7c3e246145a3f6c7d9def56545fcd4ab Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 08:15:18 +0000 Subject: [PATCH 1/3] fix: Prefill every parameter editor with the value it has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interactive mode shows the value each parameter was given, but opening one to change it starts from nothing: a text prompt opens blank, a list opens with nothing selected, a confirm opens on true whatever the value is, and an object parameter is edited on an empty params object, so its value is dropped the moment it is looked at. Every editor now opens on the value the parameter has, whether it came from an argument, from the JSON piped in, or from an earlier trip through the menu. Params are passed through as given, so a value need not match the format its parameter documents: one that does not fit the editor is left out of it and the editor opens as it does for an unset parameter. A resource picker opens on the resource already chosen, unless the list does not offer it — clack answers with no value at all for a selection with no choice behind it, which would blank the parameter. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BFLnh3eSDcivDPtu8r1bBy --- README.md | 5 +- src/lib/interactions/access-code.ts | 18 +- src/lib/interactions/acs-entrance.ts | 3 +- src/lib/interactions/acs-system.ts | 9 +- src/lib/interactions/acs-user.ts | 9 +- src/lib/interactions/blueprint-object.ts | 75 ++++- src/lib/interactions/connected-account.ts | 3 +- src/lib/interactions/custom-metadata.ts | 6 + src/lib/interactions/device.ts | 3 +- src/lib/interactions/resource.ts | 4 + src/lib/interactions/timestamp.ts | 5 +- src/lib/interactions/user-identity.ts | 3 +- src/lib/memory-prompt.ts | 40 ++- src/lib/prompt.test.ts | 24 ++ src/lib/prompt.ts | 69 ++++- test/interactions/blueprint-object.test.ts | 330 +++++++++++++++++++++ test/interactions/custom-metadata.test.ts | 41 ++- 17 files changed, 600 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 2229ab4b..6be9d7ce 100644 --- a/README.md +++ b/README.md @@ -45,8 +45,9 @@ 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. +you passed as arguments or piped in as JSON, and each property you open is +prefilled with the value it has, ready to edit rather than retype. 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 diff --git a/src/lib/interactions/access-code.ts b/src/lib/interactions/access-code.ts index 198df95d..cc28bae6 100644 --- a/src/lib/interactions/access-code.ts +++ b/src/lib/interactions/access-code.ts @@ -3,13 +3,16 @@ import { getSeam } from 'lib/http/client.js' import { interactForDevice } from './device.js' import { interactForResource } from './resource.js' -export const interactForAccessCode = async ({ - // The key is a Seam API parameter name: callers pass the blueprint params - // bag through as-is, so only the binding may be camelCase. - device_id: deviceId, -}: { - device_id?: string -}) => { +export const interactForAccessCode = async ( + { + // The key is a Seam API parameter name: callers pass the blueprint params + // bag through as-is, so only the binding may be camelCase. + device_id: deviceId, + }: { + device_id?: string + }, + initialValue?: string, +) => { const seam = await getSeam() if (!deviceId) { @@ -19,6 +22,7 @@ export const interactForAccessCode = async ({ return interactForResource({ resourceName: 'access_code', fetchResources: () => seam.accessCodes.list({ device_id: deviceId }), + initialValue, toChoice: (accessCode) => ({ title: accessCode.name ?? '', value: accessCode.access_code_id, diff --git a/src/lib/interactions/acs-entrance.ts b/src/lib/interactions/acs-entrance.ts index 0e08533b..68420b1b 100644 --- a/src/lib/interactions/acs-entrance.ts +++ b/src/lib/interactions/acs-entrance.ts @@ -2,12 +2,13 @@ import { getSeam } from 'lib/http/client.js' import { interactForResource } from './resource.js' -export const interactForAcsEntrance = async () => { +export const interactForAcsEntrance = async (initialValue?: string) => { const seam = await getSeam() return interactForResource({ resourceName: 'ACS entrance', fetchResources: () => seam.acs.entrances.list(), + initialValue, toChoice: (entrance) => ({ title: entrance.display_name ?? '', value: entrance.acs_entrance_id, diff --git a/src/lib/interactions/acs-system.ts b/src/lib/interactions/acs-system.ts index ef4cff7f..3bd3fea2 100644 --- a/src/lib/interactions/acs-system.ts +++ b/src/lib/interactions/acs-system.ts @@ -2,13 +2,20 @@ import { getSeam } from 'lib/http/client.js' import { interactForResource } from './resource.js' -export const interactForAcsSystem = async (message?: string) => { +export const interactForAcsSystem = async ({ + message, + initialValue, +}: { + message?: string | undefined + initialValue?: string | undefined +} = {}) => { const seam = await getSeam() return interactForResource({ resourceName: 'ACS system', fetchResources: () => seam.acs.systems.list(), message, + initialValue, toChoice: (system) => ({ title: `${system.name} ${system.external_type_display_name}`, value: system.acs_system_id, diff --git a/src/lib/interactions/acs-user.ts b/src/lib/interactions/acs-user.ts index c74409db..e69ad4c7 100644 --- a/src/lib/interactions/acs-user.ts +++ b/src/lib/interactions/acs-user.ts @@ -3,16 +3,17 @@ import { getSeam } from 'lib/http/client.js' import { interactForAcsSystem } from './acs-system.js' import { interactForResource } from './resource.js' -export const interactForAcsUser = async () => { +export const interactForAcsUser = async (initialValue?: string) => { const seam = await getSeam() - const acsSystemId = await interactForAcsSystem( - 'What acs_system does the acs_user belong to?', - ) + const acsSystemId = await interactForAcsSystem({ + message: 'What acs_system does the acs_user belong to?', + }) return interactForResource({ resourceName: 'ACS user', fetchResources: () => seam.acs.users.list({ acs_system_id: acsSystemId }), + initialValue, toChoice: (user) => ({ title: `${user.display_name} ${user.email_address}`, value: user.acs_user_id, diff --git a/src/lib/interactions/blueprint-object.ts b/src/lib/interactions/blueprint-object.ts index 0f9185f2..64e7d7dc 100644 --- a/src/lib/interactions/blueprint-object.ts +++ b/src/lib/interactions/blueprint-object.ts @@ -207,32 +207,47 @@ export const interactForBlueprintObject = async ( } } + const current = args.params[paramToEdit] + if (paramToEdit === 'device_id') { - args.params[paramToEdit] = await interactForDevice() + args.params[paramToEdit] = await interactForDevice(toText(current)) return interactForBlueprintObject(args, ctx) } else if (paramToEdit === 'access_code_id') { - args.params[paramToEdit] = await interactForAccessCode(args.params as any) + args.params[paramToEdit] = await interactForAccessCode( + args.params as any, + toText(current), + ) return interactForBlueprintObject(args, ctx) } else if (paramToEdit === 'connected_account_id') { - const connectedAccountId = await interactForConnectedAccount() + const connectedAccountId = await interactForConnectedAccount( + toText(current), + ) args.params[paramToEdit] = connectedAccountId return interactForBlueprintObject(args, ctx) } else if ( paramToEdit === 'user_identity_id' || paramToEdit === 'user_identity_ids' ) { - const userIdentityId = await interactForUserIdentity() + const userIdentityId = await interactForUserIdentity( + paramToEdit === 'user_identity_ids' + ? toTextList(current)[0] + : toText(current), + ) args.params[paramToEdit] = paramToEdit === 'user_identity_ids' ? [userIdentityId] : userIdentityId return interactForBlueprintObject(args, ctx) } else if (paramToEdit.endsWith('acs_system_id')) { - args.params[paramToEdit] = await interactForAcsSystem() + args.params[paramToEdit] = await interactForAcsSystem({ + initialValue: toText(current), + }) return interactForBlueprintObject(args, ctx) } else if (paramToEdit.endsWith('acs_user_id')) { - args.params[paramToEdit] = await interactForAcsUser() + args.params[paramToEdit] = await interactForAcsUser(toText(current)) return interactForBlueprintObject(args, ctx) } else if (paramToEdit.endsWith('acs_entrance_id')) { - args.params['acs_entrance_id'] = await interactForAcsEntrance() + args.params['acs_entrance_id'] = await interactForAcsEntrance( + toText(args.params['acs_entrance_id']), + ) return interactForBlueprintObject(args, ctx) } else if ( paramToEdit.endsWith('_at') || @@ -240,14 +255,14 @@ export const interactForBlueprintObject = async ( paramToEdit.endsWith('_before') || paramToEdit.endsWith('_after') ) { - args.params[paramToEdit] = await interactForTimestamp() + args.params[paramToEdit] = await interactForTimestamp(toText(current)) return interactForBlueprintObject(args, ctx) } else if ( paramToEdit === 'custom_metadata' || paramToEdit === 'custom_metadata_has' ) { args.params[paramToEdit] = await interactForCustomMetadata( - args.params[paramToEdit] || {}, + toRecord(current), ) return interactForBlueprintObject(args, ctx) } @@ -256,10 +271,11 @@ export const interactForBlueprintObject = async ( if (['string', 'id', 'datetime'].includes(prop.format)) { let value if (prop.format === 'datetime') { - value = await interactForTimestamp() + value = await interactForTimestamp(toText(current)) } else { value = await promptText({ message: withBackHint(`${paramToEdit}:`), + initialValue: toText(current), }) } args.params[paramToEdit] = value @@ -271,13 +287,14 @@ export const interactForBlueprintObject = async ( label: v.name, value: v.name, })), + initialValue: toText(current), }) args.params[paramToEdit] = value return interactForBlueprintObject(args, ctx) } else if (prop.format === 'boolean') { const value = await promptConfirm({ message: withBackHint(`${paramToEdit}:`), - initialValue: true, + initialValue: toBoolean(current) ?? true, active: 'true', inactive: 'false', }) @@ -292,12 +309,13 @@ export const interactForBlueprintObject = async ( label: v.name, value: v.name, })), + initialValues: toTextList(current), }) args.params[paramToEdit] = value return interactForBlueprintObject(args, ctx) } else if (prop.format === 'list') { args.params[paramToEdit] = await interactForArray( - args.params[paramToEdit] || [], + toTextList(current), `Edit the list for ${paramToEdit}`, ) return interactForBlueprintObject(args, ctx) @@ -305,7 +323,7 @@ export const interactForBlueprintObject = async ( args.params[paramToEdit] = await interactForBlueprintObject( { command: args.command, - params: {}, + params: toRecord(current), parameters: prop.parameters, isSubProperty: true, subPropertyPath: paramToEdit, @@ -316,6 +334,7 @@ export const interactForBlueprintObject = async ( } else if (prop.format === 'number') { const value = await promptNumber({ message: withBackHint(`${paramToEdit}:`), + initialValue: toNumber(current), }) args.params[paramToEdit] = value @@ -332,3 +351,33 @@ export const interactForBlueprintObject = async ( `Didn't know how to handle Blueprint parameter for property: "${paramToEdit}"`, ) } + +/* + * A parameter's current value in the shape its editor starts from, so that + * editing a parameter that already has a value continues from that value + * instead of from nothing — whether it came from an argument, from the JSON + * piped in, or from an earlier trip through this menu. + * + * Params are passed through as given, so a value need not match the format + * its parameter documents: the JSON piped in is arbitrary, and a schema can + * change under a script. A value the editor cannot start from is left out of + * it, which opens the editor as an unset parameter does, rather than + * rendering a value of the wrong type as text to edit. + */ + +const toText = (value: unknown): string | undefined => + typeof value === 'string' ? value : undefined + +const toNumber = (value: unknown): number | undefined => + typeof value === 'number' ? value : undefined + +const toBoolean = (value: unknown): boolean | undefined => + typeof value === 'boolean' ? value : undefined + +const toTextList = (value: unknown): string[] => + Array.isArray(value) ? value.filter((item) => typeof item === 'string') : [] + +const toRecord = (value: unknown): Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : {} diff --git a/src/lib/interactions/connected-account.ts b/src/lib/interactions/connected-account.ts index 7892bfd1..2ca83086 100644 --- a/src/lib/interactions/connected-account.ts +++ b/src/lib/interactions/connected-account.ts @@ -1,12 +1,13 @@ import { getSeam } from 'lib/http/client.js' import { interactForResource } from './resource.js' -export const interactForConnectedAccount = async () => { +export const interactForConnectedAccount = async (initialValue?: string) => { const seam = await getSeam() return interactForResource({ resourceName: 'connected_account', fetchResources: () => seam.connectedAccounts.list(), + initialValue, toChoice: (connectedAccount) => { const identifiers = Object.values( connectedAccount.user_identifier ?? {}, diff --git a/src/lib/interactions/custom-metadata.ts b/src/lib/interactions/custom-metadata.ts index 72ef462e..38e4a5e6 100644 --- a/src/lib/interactions/custom-metadata.ts +++ b/src/lib/interactions/custom-metadata.ts @@ -53,10 +53,16 @@ export const interactForCustomMetadata = async ( message: withBackHint('Enter a key to add or edit:'), }) + // Editing an existing key continues from the value it has, so a value + // is corrected rather than retyped. + const currentValue = updatedCustomMetadata[newKey] + let newValue: string | boolean = await promptText({ message: withBackHint( 'Enter the new value to add or edit (or null to delete):', ), + initialValue: + currentValue == null ? undefined : currentValue.toString(), }) if (newKey) { if (newValue === 'false' || newValue === 'true') { diff --git a/src/lib/interactions/device.ts b/src/lib/interactions/device.ts index 72043e70..21b4a45c 100644 --- a/src/lib/interactions/device.ts +++ b/src/lib/interactions/device.ts @@ -1,12 +1,13 @@ import { getSeam } from 'lib/http/client.js' import { interactForResource } from './resource.js' -export const interactForDevice = async () => { +export const interactForDevice = async (initialValue?: string) => { const seam = await getSeam() return interactForResource({ resourceName: 'device', fetchResources: () => seam.devices.list(), + initialValue, toChoice: (device) => ({ title: device.properties.name ?? '', value: device.device_id, diff --git a/src/lib/interactions/resource.ts b/src/lib/interactions/resource.ts index 10b2cfa0..e759a129 100644 --- a/src/lib/interactions/resource.ts +++ b/src/lib/interactions/resource.ts @@ -12,11 +12,14 @@ export const interactForResource = async ({ fetchResources, toChoice, message = `Select a ${resourceName}:`, + initialValue, }: { resourceName: string fetchResources: () => Promise toChoice: (resource: Resource) => ResourceChoice message?: string | undefined + /** The resource already chosen, for the list to open on. */ + initialValue?: string | undefined }) => { const resources = await withLoading( `Fetching ${resourceName.replace(/_/g, ' ')}s...`, @@ -30,5 +33,6 @@ export const interactForResource = async ({ const { title, value, description } = toChoice(resource) return { label: title, value, hint: description } }), + initialValue, }) } diff --git a/src/lib/interactions/timestamp.ts b/src/lib/interactions/timestamp.ts index dda6467e..baa9480e 100644 --- a/src/lib/interactions/timestamp.ts +++ b/src/lib/interactions/timestamp.ts @@ -1,11 +1,14 @@ import { promptText, withBackHint } from 'lib/prompt.js' -export const interactForTimestamp = async () => { +export const interactForTimestamp = async (currentValue?: string) => { const now = new Date().toISOString() const timestamp = await promptText({ message: withBackHint('Enter a timestamp:'), placeholder: now, defaultValue: now, + // A timestamp already given is offered for editing, rather than making + // the user type it out again to shift it by an hour. + initialValue: currentValue, validate: (value) => { if (value == null || value === '') return undefined if (Number.isNaN(new Date(value).getTime())) { diff --git a/src/lib/interactions/user-identity.ts b/src/lib/interactions/user-identity.ts index be153306..e554276c 100644 --- a/src/lib/interactions/user-identity.ts +++ b/src/lib/interactions/user-identity.ts @@ -2,12 +2,13 @@ import { getSeam } from 'lib/http/client.js' import { interactForResource } from './resource.js' -export const interactForUserIdentity = async () => { +export const interactForUserIdentity = async (initialValue?: string) => { const seam = await getSeam() return interactForResource({ resourceName: 'user_identity', fetchResources: () => seam.userIdentities.list(), + initialValue, toChoice: (userIdentity) => ({ title: `${userIdentity.email_address} "${userIdentity.full_name}: ${userIdentity.user_identity_key}`, value: userIdentity.user_identity_id, diff --git a/src/lib/memory-prompt.ts b/src/lib/memory-prompt.ts index 448c2482..e26cfe33 100644 --- a/src/lib/memory-prompt.ts +++ b/src/lib/memory-prompt.ts @@ -3,6 +3,7 @@ import type { PromptChoice, PromptClient, PromptConfirmOptions, + PromptMultiselectOptions, PromptNumberOptions, PromptSelectOptions, PromptTextOptions, @@ -19,6 +20,9 @@ export interface PromptQuestion { | 'autocompleteMultiselect' message: string choices?: Array> + /** What the question was seeded with — what the user sees to edit. */ + initialValue?: unknown + initialValues?: unknown[] | undefined } /** Scripted in place of an answer to dismiss that prompt. */ @@ -44,35 +48,53 @@ export class MemoryPromptClient implements PromptClient { canPrompt = (): boolean => true - text = async ({ message }: PromptTextOptions): Promise => - this.answer({ kind: 'text', message }) as string + text = async ({ + message, + initialValue, + }: PromptTextOptions): Promise => + this.answer({ kind: 'text', message, initialValue }) as string - number = async ({ message }: PromptNumberOptions): Promise => - this.answer({ kind: 'number', message }) as number + number = async ({ + message, + initialValue, + }: PromptNumberOptions): Promise => + this.answer({ kind: 'number', message, initialValue }) as number - confirm = async ({ message }: PromptConfirmOptions): Promise => - this.answer({ kind: 'confirm', message }) as boolean + confirm = async ({ + message, + initialValue, + }: PromptConfirmOptions): Promise => + this.answer({ kind: 'confirm', message, initialValue }) as boolean select = async ({ message, choices, + initialValue, }: PromptSelectOptions): Promise => - this.answer({ kind: 'select', message, choices }) as Value + this.answer({ kind: 'select', message, choices, initialValue }) as Value autocomplete = async ({ message, choices, + initialValue, }: PromptSelectOptions): Promise => - this.answer({ kind: 'autocomplete', message, choices }) as Value + this.answer({ + kind: 'autocomplete', + message, + choices, + initialValue, + }) as Value autocompleteMultiselect = async ({ message, choices, - }: PromptSelectOptions): Promise => + initialValues, + }: PromptMultiselectOptions): Promise => this.answer({ kind: 'autocompleteMultiselect', message, choices, + initialValues, }) as Value[] private answer(question: PromptQuestion): unknown { diff --git a/src/lib/prompt.test.ts b/src/lib/prompt.test.ts index 4fb49813..7ad05806 100644 --- a/src/lib/prompt.test.ts +++ b/src/lib/prompt.test.ts @@ -6,6 +6,8 @@ import { expect, test } from 'vitest' import { arrowKeyFor, emitArrowKeyAliases, + offeredValue, + offeredValues, type SearchableChoice, searchChoices, } from 'lib/prompt.js' @@ -91,3 +93,25 @@ test('emitArrowKeyAliases: re-emits control keypresses as arrow keys', () => { ['a', { name: 'a', sequence: 'a' }], ]) }) + +const choices = [ + { label: 'Sandbox', value: 'ws_1' }, + { label: 'Production', value: 'ws_2' }, +] + +test('offeredValue: keeps a value the list offers', () => { + expect(offeredValue(choices, 'ws_2')).toBe('ws_2') +}) + +// A value can come from an argument naming something the list does not hold: +// a deleted device, or a resource on another page of results. +test('offeredValue: drops a value the list does not offer', () => { + expect(offeredValue(choices, 'ws_3')).toBeUndefined() + expect(offeredValue(choices, undefined)).toBeUndefined() + expect(offeredValue([], 'ws_1')).toBeUndefined() +}) + +test('offeredValues: keeps only the values the list offers', () => { + expect(offeredValues(choices, ['ws_3', 'ws_1'])).toEqual(['ws_1']) + expect(offeredValues(choices, undefined)).toEqual([]) +}) diff --git a/src/lib/prompt.ts b/src/lib/prompt.ts index 45528e0d..3fad9bb8 100644 --- a/src/lib/prompt.ts +++ b/src/lib/prompt.ts @@ -24,11 +24,15 @@ export interface PromptTextOptions { message: string placeholder?: string defaultValue?: string + /** Editable text the prompt opens with, for editing a value in place. */ + initialValue?: string | undefined validate?: (value: string | undefined) => string | undefined } export interface PromptNumberOptions { message: string + /** Editable text the prompt opens with, for editing a value in place. */ + initialValue?: number | undefined validate?: (value: number) => string | undefined } @@ -42,6 +46,15 @@ export interface PromptConfirmOptions { export interface PromptSelectOptions { message: string choices: Array> + /** The choice to open on, for editing a value in place. */ + initialValue?: Value | undefined +} + +export interface PromptMultiselectOptions { + message: string + choices: Array> + /** The choices to open selected, for editing a value in place. */ + initialValues?: Value[] | undefined } /** @@ -60,7 +73,7 @@ export interface PromptClient { select: (options: PromptSelectOptions) => Promise autocomplete: (options: PromptSelectOptions) => Promise autocompleteMultiselect: ( - options: PromptSelectOptions, + options: PromptMultiselectOptions, ) => Promise } @@ -133,6 +146,37 @@ const toOptions = ( : { label, value, hint }) as Option, ) +/** + * A value as the choice a list prompt opens on: the value when the list + * offers it, and otherwise nothing. + * + * A value the list does not offer, such as an id passed as an argument that + * is not among the resources fetched, would leave clack holding a selection + * with no choice behind it — so the prompt opens on its first choice, the + * same as one with no value to start from. + */ +export const offeredValue = ( + choices: Array>, + value: Value | undefined, +): Value | undefined => + choices.some((choice) => choice.value === value) ? value : undefined + +/** Every one of the values a list prompt offers, in the order given. */ +export const offeredValues = ( + choices: Array>, + values: Value[] | undefined, +): Value[] => + (values ?? []).filter((value) => offeredValue(choices, value) !== undefined) + +// Options a clack prompt is given cannot carry an explicit undefined: it +// declares each optional field without it, and the CLI type-checks with +// exactOptionalPropertyTypes. +const optional = ( + key: Key, + value: Value | undefined, +): Partial> => + value === undefined ? {} : ({ [key]: value } as Record) + export class TerminalPromptClient implements PromptClient { /** * Prompts read raw keypresses and render an interface, so they need a @@ -145,7 +189,14 @@ export class TerminalPromptClient implements PromptClient { text = async (options: PromptTextOptions): Promise => { installArrowKeyAliases() - return unwrap(await text({ ...options, output })) + const { initialValue, ...rest } = options + return unwrap( + await text({ + ...rest, + ...optional('initialValue', initialValue), + output, + }), + ) } number = async (options: PromptNumberOptions): Promise => { @@ -153,6 +204,7 @@ export class TerminalPromptClient implements PromptClient { const value = unwrap( await text({ message: options.message, + ...optional('initialValue', options.initialValue?.toString()), validate: (value) => { if (value == null || value.trim() === '') return 'Enter a number' const parsed = Number(value) @@ -178,6 +230,10 @@ export class TerminalPromptClient implements PromptClient { await select({ message: options.message, options: toOptions(options.choices), + ...optional( + 'initialValue', + offeredValue(options.choices, options.initialValue), + ), output, }), ) @@ -191,6 +247,10 @@ export class TerminalPromptClient implements PromptClient { await autocomplete({ message: options.message, options: toOptions(options.choices), + ...optional( + 'initialValue', + offeredValue(options.choices, options.initialValue), + ), // Search a list by any part of a name or hint, rather than only by // the label, which is all clack matches for itself. filter: searchChoices, @@ -200,13 +260,14 @@ export class TerminalPromptClient implements PromptClient { } autocompleteMultiselect = async ( - options: PromptSelectOptions, + options: PromptMultiselectOptions, ): Promise => { installArrowKeyAliases() return unwrap( await autocompleteMultiselect({ message: options.message, options: toOptions(options.choices), + initialValues: offeredValues(options.choices, options.initialValues), filter: searchChoices, output, }), @@ -273,7 +334,7 @@ export const promptAutocomplete = async ( } export const promptAutocompleteMultiselect = async ( - options: PromptSelectOptions, + options: PromptMultiselectOptions, ): Promise => { ensureInteractive() return await client.autocompleteMultiselect(options) diff --git a/test/interactions/blueprint-object.test.ts b/test/interactions/blueprint-object.test.ts index 3e10177f..bb854ece 100644 --- a/test/interactions/blueprint-object.test.ts +++ b/test/interactions/blueprint-object.test.ts @@ -7,6 +7,7 @@ import { cancelPrompt, createMemoryPrompt, type MemoryPromptClient, + type PromptQuestion, } from 'lib/memory-prompt.js' import { setOutput } from 'lib/output/get-output.js' import { createMemoryOutput } from 'lib/output/memory-output.js' @@ -44,6 +45,15 @@ const args = (params: Record) => ({ params, }) +/** The question of a kind the user was asked, for a flow that asks one. */ +const questionOfKind = (kind: PromptQuestion['kind']): PromptQuestion => { + const question = memoryPrompt.questions.find( + (question) => question.kind === kind, + ) + if (question == null) throw new Error(`No ${kind} prompt was asked`) + return question +} + test('interactForBlueprintObject: submits without prompting once every required parameter is given', async () => { await expect( interactForBlueprintObject(args({ device_id: 'device1' }), ctx('auto')), @@ -240,6 +250,326 @@ test.for(['custom_metadata', 'custom_metadata_has'] as const)( }, ) +// Every editor opens on the value the parameter already has — from an +// argument, from the JSON piped in, or from an earlier trip through the menu — +// so that changing a value is an edit and not a retype. + +test('interactForBlueprintObject: opens a text prompt on the value the parameter has', async () => { + scriptPrompt(['name', 'value', 'Back Door', 'done']) + + await expect( + interactForBlueprintObject( + args({ device_id: 'device1', name: 'Front Door' }), + ctx('interactive'), + ), + ).resolves.toEqual({ device_id: 'device1', name: 'Back Door' }) + + expect(questionOfKind('text')).toMatchObject({ + message: withBackHint('name:'), + initialValue: 'Front Door', + }) +}) + +test('interactForBlueprintObject: opens a text prompt empty for a parameter with no value', async () => { + scriptPrompt(['name', 'Front Door', 'done']) + + await interactForBlueprintObject( + args({ device_id: 'device1' }), + ctx('interactive'), + ) + + expect(questionOfKind('text').initialValue).toBeUndefined() +}) + +// Params are passed through as given, so a value need not match the format +// its parameter documents: the JSON piped in is arbitrary JSON. +test('interactForBlueprintObject: opens a text prompt empty for a value that is not text', async () => { + scriptPrompt(['name', 'value', 'Front Door', 'done']) + + await interactForBlueprintObject( + args({ device_id: 'device1', name: 3 }), + ctx('interactive'), + ) + + expect(questionOfKind('text').initialValue).toBeUndefined() +}) + +const numberParameters = [ + { name: 'limit', isRequired: false, format: 'number' }, +] as unknown as Parameter[] + +test('interactForBlueprintObject: opens a number prompt on the value the parameter has', async () => { + scriptPrompt(['limit', 'value', 50, 'done']) + + await expect( + interactForBlueprintObject( + { + command: ['devices', 'list'], + parameters: numberParameters, + params: { limit: 25 }, + }, + ctx('interactive'), + ), + ).resolves.toEqual({ limit: 50 }) + + expect(questionOfKind('number').initialValue).toBe(25) +}) + +const enumParameters = [ + { + name: 'sort_direction', + isRequired: false, + format: 'enum', + values: [{ name: 'asc' }, { name: 'desc' }], + }, +] as unknown as Parameter[] + +test('interactForBlueprintObject: opens an enum list on the value the parameter has', async () => { + scriptPrompt(['sort_direction', 'value', 'asc', 'done']) + + await expect( + interactForBlueprintObject( + { + command: ['devices', 'list'], + parameters: enumParameters, + params: { sort_direction: 'desc' }, + }, + ctx('interactive'), + ), + ).resolves.toEqual({ sort_direction: 'asc' }) + + // The action menu is a select too, so the enum list is the second one. + expect( + memoryPrompt.questions.filter(({ kind }) => kind === 'select')[1], + ).toMatchObject({ initialValue: 'desc' }) +}) + +const booleanParameters = [ + { name: 'enabled', isRequired: false, format: 'boolean' }, +] as unknown as Parameter[] + +// A boolean confirm opens on `true` for a parameter with no value, so a value +// of `false` is exactly the one a prefill has to carry. +test.for([true, false] as const)( + 'interactForBlueprintObject: opens a confirm on the value %s the parameter has', + async (given) => { + scriptPrompt(['enabled', 'value', given, 'done']) + + await interactForBlueprintObject( + { + command: ['devices', 'list'], + parameters: booleanParameters, + params: { enabled: given }, + }, + ctx('interactive'), + ) + + expect(questionOfKind('confirm').initialValue).toBe(given) + }, +) + +const enumListParameters = [ + { + name: 'device_types', + isRequired: false, + format: 'list', + itemFormat: 'enum', + itemEnumValues: [{ name: 'august_lock' }, { name: 'schlage_lock' }], + }, +] as unknown as Parameter[] + +test('interactForBlueprintObject: opens an enum list editor with the values the parameter has selected', async () => { + scriptPrompt(['device_types', 'value', ['schlage_lock'], 'done']) + + await expect( + interactForBlueprintObject( + { + command: ['devices', 'list'], + parameters: enumListParameters, + params: { device_types: ['august_lock'] }, + }, + ctx('interactive'), + ), + ).resolves.toEqual({ device_types: ['schlage_lock'] }) + + expect(questionOfKind('autocompleteMultiselect').initialValues).toEqual([ + 'august_lock', + ]) +}) + +const listParameters = [ + { name: 'device_ids', isRequired: false, format: 'list' }, +] as unknown as Parameter[] + +const listArgs = (params: Record) => ({ + command: ['devices', 'list'], + parameters: listParameters, + params, +}) + +test('interactForBlueprintObject: opens the list editor on the list the parameter has', async () => { + // Pick the parameter, enter its editor, finish editing, then submit. + scriptPrompt(['device_ids', 'value', 'done', 'done']) + + await expect( + interactForBlueprintObject( + listArgs({ device_ids: ['device1', 'device2'] }), + ctx('interactive'), + ), + ).resolves.toEqual({ device_ids: ['device1', 'device2'] }) +}) + +// The list editor spreads what it is given, so a value that is not a list +// would be edited one character at a time. +test('interactForBlueprintObject: opens the list editor empty for a value that is not a list', async () => { + scriptPrompt(['device_ids', 'value', 'done', 'done']) + + await expect( + interactForBlueprintObject( + listArgs({ device_ids: 'device1' }), + ctx('interactive'), + ), + ).resolves.toEqual({ device_ids: [] }) +}) + +// A timestamp parameter is routed by name, ahead of the format branches. +test('interactForBlueprintObject: opens the timestamp prompt on the value the parameter has', async () => { + scriptPrompt(['starts_at', 'value', '2026-08-13T00:00:00.000Z', 'done']) + + await interactForBlueprintObject( + { + command: ['access_codes', 'create'], + parameters: [ + { name: 'starts_at', isRequired: false, format: 'datetime' }, + ] as unknown as Parameter[], + params: { starts_at: '2026-08-12T00:00:00.000Z' }, + }, + ctx('interactive'), + ) + + expect(questionOfKind('text').initialValue).toBe('2026-08-12T00:00:00.000Z') +}) + +// An object parameter is edited by this same menu one level down, so it has to +// be given the value it is editing rather than an empty params object: the +// object the menu returns is the request body, so anything it drops is sent +// missing. +const objectParameters = [ + { + name: 'user_identity', + isRequired: false, + format: 'object', + parameters: [ + { name: 'full_name', isRequired: true, format: 'string' }, + { name: 'user_identity_key', isRequired: false, format: 'string' }, + ], + }, +] as unknown as Parameter[] + +const userIdentity = { full_name: 'Jane Doe', user_identity_key: 'jane' } + +const objectArgs = (params: Record) => ({ + command: ['access_grants', 'create'], + parameters: objectParameters, + params, +}) + +const objectEditorChoices = (): Array<{ value: string; hint?: string }> => { + const question = memoryPrompt.questions.find( + ({ message }) => message === withBackHint('Editing "user_identity"'), + ) + if (question?.choices == null) throw new Error('The editor was not opened') + return question.choices as Array<{ value: string; hint?: string }> +} + +test('interactForBlueprintObject: opens an object editor on the value the parameter has', async () => { + // Pick the parameter, choose to enter a value, then leave its editor. + scriptPrompt(['user_identity', 'value', 'back', 'done']) + + await interactForBlueprintObject( + objectArgs({ user_identity: { ...userIdentity } }), + ctx('interactive'), + ) + + expect( + objectEditorChoices().find(({ value }) => value === 'full_name'), + ).toMatchObject({ hint: '[Jane Doe]' }) +}) + +test.for([['leaving', 'back'] as const, ['dismissing', cancelPrompt] as const])( + 'interactForBlueprintObject: %s an object editor keeps the value the parameter has', + async ([, answer]) => { + scriptPrompt(['user_identity', 'value', answer, 'done']) + + await expect( + interactForBlueprintObject( + objectArgs({ user_identity: { ...userIdentity } }), + ctx('interactive'), + ), + ).resolves.toEqual({ user_identity: userIdentity }) + }, +) + +test('interactForBlueprintObject: edits one sub-property of the value the parameter has', async () => { + // Pick the parameter, enter its editor, edit one sub-property, save, submit. + scriptPrompt([ + 'user_identity', + 'value', + 'user_identity_key', + 'value', + 'jane-2', + 'done', + 'done', + ]) + + await expect( + interactForBlueprintObject( + objectArgs({ user_identity: { ...userIdentity } }), + ctx('interactive'), + ), + ).resolves.toEqual({ + user_identity: { full_name: 'Jane Doe', user_identity_key: 'jane-2' }, + }) + + expect(questionOfKind('text').initialValue).toBe('jane') +}) + +test('interactForBlueprintObject: opens an object editor empty for a parameter with no value', async () => { + scriptPrompt(['user_identity', 'full_name', 'Jane Doe', 'done', 'done']) + + await expect( + interactForBlueprintObject(objectArgs({}), ctx('interactive')), + ).resolves.toEqual({ user_identity: { full_name: 'Jane Doe' } }) +}) + +test.for([['text', 'nope'] as const, ['a list', ['nope']] as const])( + 'interactForBlueprintObject: opens an object editor empty for a value that is %s', + async ([, given]) => { + scriptPrompt(['user_identity', 'value', 'back', 'done']) + + await interactForBlueprintObject( + objectArgs({ user_identity: given }), + ctx('interactive'), + ) + + expect(objectEditorChoices().map(({ value }) => value)).toEqual([ + 'full_name', + 'user_identity_key', + 'empty', + 'back', + ]) + }, +) + +test('interactForBlueprintObject: leaves the given params unmodified', async () => { + scriptPrompt(['user_identity', 'value', 'full_name', 'value', 'Ada', 'done']) + const params = { user_identity: { ...userIdentity } } + + await interactForBlueprintObject(objectArgs(params), ctx('interactive')) + + expect(params).toEqual({ user_identity: userIdentity }) +}) + test('interactForBlueprintObject: dismissing the parameter menu leaves the command', async () => { scriptPrompt([cancelPrompt]) diff --git a/test/interactions/custom-metadata.test.ts b/test/interactions/custom-metadata.test.ts index 8339925f..42fc10b3 100644 --- a/test/interactions/custom-metadata.test.ts +++ b/test/interactions/custom-metadata.test.ts @@ -1,14 +1,21 @@ import { afterEach, beforeEach, expect, test } from 'vitest' import { interactForCustomMetadata } from 'lib/interactions/index.js' -import { createMemoryPrompt } from 'lib/memory-prompt.js' +import { + createMemoryPrompt, + type MemoryPromptClient, + type PromptQuestion, +} from 'lib/memory-prompt.js' import { setOutput } from 'lib/output/get-output.js' import { createMemoryOutput } from 'lib/output/memory-output.js' import { resetPromptClient, setPromptClient } from 'lib/prompt.js' +let memoryPrompt: MemoryPromptClient + /** Scripts an answer for each ask, in the order the editor asks. */ const scriptPrompt = (script: unknown[]): void => { - setPromptClient(createMemoryPrompt(script)) + memoryPrompt = createMemoryPrompt(script) + setPromptClient(memoryPrompt) } beforeEach(() => { @@ -18,6 +25,15 @@ beforeEach(() => { afterEach(resetPromptClient) +/** The last value the editor asked for: it asks for a key, then a value. */ +const valueQuestion = (): PromptQuestion => { + const question = memoryPrompt.questions + .filter(({ kind }) => kind === 'text') + .at(-1) + if (question == null) throw new Error('No value was asked for') + return question +} + test('interactForCustomMetadata: adds a key and value', async () => { scriptPrompt(['add', 'floor', '3', 'done']) @@ -52,6 +68,27 @@ test.for([['true', true] as const, ['false', false] as const])( }, ) +// The same prompt adds a key and edits one, so editing has to open on the +// value that key has rather than blank. +test.for([['3', '3'] as const, [true, 'true'] as const])( + 'interactForCustomMetadata: opens the value prompt on the value %s a key has', + async ([stored, shown]) => { + scriptPrompt(['add', 'floor', '4', 'done']) + + await interactForCustomMetadata({ floor: stored }) + + expect(valueQuestion()).toMatchObject({ initialValue: shown }) + }, +) + +test('interactForCustomMetadata: opens the value prompt empty for a new key', async () => { + scriptPrompt(['add', 'wing', 'east', 'done']) + + await interactForCustomMetadata({ floor: '3' }) + + expect(valueQuestion().initialValue).toBeUndefined() +}) + test('interactForCustomMetadata: stores null for the null keyword', async () => { scriptPrompt(['add', 'note', 'null', 'done']) From 8ae59521b957c96c1b3e4f0a566fa62e6d3184ce Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Thu, 13 Aug 2026 10:12:46 -0700 Subject: [PATCH 2/3] feat: Add --raw arg --- README.md | 7 +++++-- src/bin/cli.ts | 16 ++++++++++++++-- src/lib/args/parse.ts | 1 + src/lib/commands/spec.ts | 8 ++++++++ src/lib/render/help.ts | 2 +- test/cli.test.ts | 13 +++++++++++++ 6 files changed, 42 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 6be9d7ce..439d90f7 100644 --- a/README.md +++ b/README.md @@ -138,8 +138,8 @@ one yourself. Run `seam --help` to see whether a command paginates. ### JSON -Request params may be piped or redirected in as a JSON object. Params given as -arguments win over params read from stdin. +Request params may be piped or redirected in as a JSON object, or passed +inline with `--raw`. Params given as arguments win over raw or stdin params. An argument the command does not accept is an error, so a typo is reported rather than sent. Params read from stdin are passed through as given, so @@ -152,6 +152,9 @@ seam locks unlock-door < params.json # Or from another program echo '{"device_id": "'"$MY_DOOR"'"}' | seam locks unlock-door +# Pass request params inline as JSON +seam devices list --raw '{"search":"bar"}' + # --device-id wins over any device_id in params.json seam devices list --limit 5 < params.json ``` diff --git a/src/bin/cli.ts b/src/bin/cli.ts index 55899a8c..baf687a2 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -29,7 +29,10 @@ import { createSeamApi, type SeamApi } from 'lib/http/api.js' import { interactForCommandSelection } from 'lib/interactions/index.js' import { getOutput, setOutput } from 'lib/output/get-output.js' import { createOutput } from 'lib/output/output.js' -import { readStdinJson } from 'lib/output/read-stdin-json.js' +import { + parseJsonParams, + readStdinJson, +} from 'lib/output/read-stdin-json.js' import { resolveOutputFormat } from 'lib/output/resolve-output-format.js' import { setAuthOverrides } from 'lib/overrides.js' import { canPrompt } from 'lib/prompt.js' @@ -168,7 +171,16 @@ async function cli(args: ParsedArgs, argv: string[]) { // Params piped or redirected in, e.g., `seam devices list < params.json`. const pipedParams = await readStdinJson() - const stdinParams: Record = { ...pipedParams } + const rawParams = + args['raw'] == null + ? null + : parseJsonParams(String(args['raw']), '--raw') + // Inline raw params take precedence over piped params, while ordinary + // command arguments still take precedence over both. + const stdinParams: Record = { + ...pipedParams, + ...rawParams, + } const auth = resolveAuth(config) let seamApi: Promise | null = null diff --git a/src/lib/args/parse.ts b/src/lib/args/parse.ts index 7144d2be..2217e6d7 100644 --- a/src/lib/args/parse.ts +++ b/src/lib/args/parse.ts @@ -35,6 +35,7 @@ export const cliFlags: string[] = [ 'h', 'help', 'json', + 'raw', 'remote_schema', 'update', 'version', diff --git a/src/lib/commands/spec.ts b/src/lib/commands/spec.ts index 4f5a2ca0..49a88629 100644 --- a/src/lib/commands/spec.ts +++ b/src/lib/commands/spec.ts @@ -104,6 +104,14 @@ export const globalFlags: CommandFlag[] = [ takesValue: false, isRequired: false, }, + { + long: 'raw', + short: null, + description: 'Pass request parameters as an inline JSON object.', + values: [], + takesValue: true, + isRequired: false, + }, { long: 'non-interactive', short: 'y', diff --git a/src/lib/render/help.ts b/src/lib/render/help.ts index a93910ac..0d2774c2 100644 --- a/src/lib/render/help.ts +++ b/src/lib/render/help.ts @@ -36,7 +36,7 @@ const outputSection = { content: [ 'Only the response is written to stdout, so it is safe to pipe. Prompts, progress, and other information are written to stderr.', 'The response is trimmed to the response key and pagination.', - 'Request params may be piped or redirected in as a JSON object. Params given as arguments win over params read from stdin.', + 'Request params may be piped or redirected in as a JSON object, or passed inline with --raw. Params given as arguments win over raw or stdin params.', ], } diff --git a/test/cli.test.ts b/test/cli.test.ts index ace7608b..a366d889 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -317,6 +317,19 @@ test('cli: sends an argument once, however it is written', async () => { expect(requests[0]?.body).toEqual({ limit: 5 }) }) +test('cli: accepts inline raw json params', async () => { + requests = [] + const { exitCode } = await runCli([ + 'devices', + 'list', + '--raw', + '{"limit":2,"nope":true}', + ]) + + expect(exitCode).toBe(0) + expect(requests[0]?.body).toEqual({ limit: 2, nope: true }) +}) + test('cli: does not hold params read from stdin to the command', async () => { requests = [] const { exitCode } = await runCli(['devices', 'list'], { From 555f9f6982c03d95f5337145e4da211c682d55cc Mon Sep 17 00:00:00 2001 From: Seam Bot Date: Thu, 13 Aug 2026 17:13:28 +0000 Subject: [PATCH 3/3] ci: Format code --- src/bin/cli.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/bin/cli.ts b/src/bin/cli.ts index baf687a2..40e96f31 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -29,10 +29,7 @@ import { createSeamApi, type SeamApi } from 'lib/http/api.js' import { interactForCommandSelection } from 'lib/interactions/index.js' import { getOutput, setOutput } from 'lib/output/get-output.js' import { createOutput } from 'lib/output/output.js' -import { - parseJsonParams, - readStdinJson, -} from 'lib/output/read-stdin-json.js' +import { parseJsonParams, readStdinJson } from 'lib/output/read-stdin-json.js' import { resolveOutputFormat } from 'lib/output/resolve-output-format.js' import { setAuthOverrides } from 'lib/overrides.js' import { canPrompt } from 'lib/prompt.js' @@ -172,9 +169,7 @@ async function cli(args: ParsedArgs, argv: string[]) { // Params piped or redirected in, e.g., `seam devices list < params.json`. const pipedParams = await readStdinJson() const rawParams = - args['raw'] == null - ? null - : parseJsonParams(String(args['raw']), '--raw') + args['raw'] == null ? null : parseJsonParams(String(args['raw']), '--raw') // Inline raw params take precedence over piped params, while ordinary // command arguments still take precedence over both. const stdinParams: Record = {