From 9020c5cd688c06a7eff503d92a31fb2d9aa954b7 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Wed, 27 May 2026 19:23:20 +0200 Subject: [PATCH 1/4] feat(persona-kit): add picker annotation to PersonaInputSpec An input can now declare `picker: { provider, resource }` to mark its value as an id chosen from a live integration-backed list (e.g. a Slack user, a Linear team). The deploy CLI uses this to offer an onboarding picker after OAuth connect instead of making operators paste raw ids. parseInputs validates and round-trips it; persona.schema.json regenerated. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../persona-kit/schemas/persona.schema.json | 22 ++++++++++++++++ packages/persona-kit/src/index.ts | 1 + packages/persona-kit/src/parse.test.ts | 22 ++++++++++++++++ packages/persona-kit/src/parse.ts | 15 ++++++++++- packages/persona-kit/src/types.ts | 26 +++++++++++++++++++ 5 files changed, 85 insertions(+), 1 deletion(-) diff --git a/packages/persona-kit/schemas/persona.schema.json b/packages/persona-kit/schemas/persona.schema.json index bf74f7e8..cdf23e47 100644 --- a/packages/persona-kit/schemas/persona.schema.json +++ b/packages/persona-kit/schemas/persona.schema.json @@ -203,10 +203,32 @@ "optional": { "type": "boolean", "description": "When true, the input is allowed to resolve to an empty string. The launcher substitutes `$NAME` with `''` rather than throwing `MissingPersonaInputError`. Use for inputs whose absence is meaningful — e.g. an upstream task description that may or may not be forwarded — and prefer non-optional inputs with a `default` for everything else so misconfigured launches surface loudly." + }, + "picker": { + "$ref": "#/definitions/PersonaInputPicker", + "description": "Declares that this input's value is an id chosen from a live list backed by an integration connection (e.g. a Slack user id, a Linear team id). When set, the deploy CLI offers the operator a picker right after the provider's OAuth connect — fetching the options through the cloud and writing the chosen id into this input — instead of making them paste a raw id. Purely an onboarding affordance: the runtime still resolves the value the usual way (explicit value → env → default), so a `picker` can coexist with `env`/`default`/`optional`." } }, "description": "Prompt-visible runtime input declared by a persona. Inputs are for non-secret run configuration such as output paths, target package names, or mode switches. Launchers resolve each input from explicit values, the process environment, or `default`, then substitute `$NAME` / `${NAME}` in the system prompt before spawning the harness." }, + "PersonaInputPicker": { + "type": "object", + "properties": { + "provider": { + "type": "string", + "description": "Integration provider whose connection backs the option list (e.g. `slack`, `linear`)." + }, + "resource": { + "type": "string", + "description": "Resource to list from that provider (e.g. `users`, `channels`, `teams`)." + } + }, + "required": [ + "provider", + "resource" + ], + "description": "How the deploy CLI sources a picker's options for a {@link PersonaInputSpec } . `provider` must be one the persona declares under `integrations` (so it is connected before the picker runs). `resource` names what to list from that provider — known values today are `users`, `channels` (Slack), and `teams` (Linear); the value is passed through to the cloud, so new resources don't require a persona-kit release." + }, "Harness": { "type": "string", "enum": [ diff --git a/packages/persona-kit/src/index.ts b/packages/persona-kit/src/index.ts index fa69554f..1228f826 100644 --- a/packages/persona-kit/src/index.ts +++ b/packages/persona-kit/src/index.ts @@ -23,6 +23,7 @@ export type { McpServerSpec, PermissionMode, PersonaContext, + PersonaInputPicker, PersonaInputSpec, PersonaInstallContext, PersonaIntegrationConfig, diff --git a/packages/persona-kit/src/parse.test.ts b/packages/persona-kit/src/parse.test.ts index 3dff24a2..1728df81 100644 --- a/packages/persona-kit/src/parse.test.ts +++ b/packages/persona-kit/src/parse.test.ts @@ -311,6 +311,28 @@ test('parseInputs rejects names that violate the env-var convention', () => { assert.throws(() => parseInputs({ foo: 'x' }, 'inputs'), /inputs\.foo must be an env-style name/); }); +test('parseInputs keeps a picker alongside env/optional', () => { + const inputs = parseInputs( + { + BENJAMIN: { env: 'BENJAMIN', optional: true, picker: { provider: 'slack', resource: 'users' } } + }, + 'inputs' + ); + assert.deepEqual(inputs?.BENJAMIN.picker, { provider: 'slack', resource: 'users' }); + assert.equal(inputs?.BENJAMIN.optional, true); +}); + +test('parseInputs rejects a picker missing provider or resource', () => { + assert.throws( + () => parseInputs({ FOO: { picker: { provider: 'slack' } } }, 'inputs'), + /inputs\.FOO\.picker\.resource must be a non-empty string/ + ); + assert.throws( + () => parseInputs({ FOO: { picker: { resource: 'users' } } }, 'inputs'), + /inputs\.FOO\.picker\.provider must be a non-empty string/ + ); +}); + test('parseHarnessSettings accepts optional codex fields and rejects bad ones', () => { const ok = parseHarnessSettings( { diff --git a/packages/persona-kit/src/parse.ts b/packages/persona-kit/src/parse.ts index c3a9fd5e..fb0ad218 100644 --- a/packages/persona-kit/src/parse.ts +++ b/packages/persona-kit/src/parse.ts @@ -313,7 +313,7 @@ export function parseInputs( if (!isObject(raw)) { throw new Error(`${context}.${name} must be a string default or an object`); } - const { description, env, default: defaultValue, optional } = raw; + const { description, env, default: defaultValue, optional, picker } = raw; const parsed: PersonaInputSpec = {}; if (description !== undefined) { if (typeof description !== 'string' || !description.trim()) { @@ -345,6 +345,19 @@ export function parseInputs( } parsed.optional = optional; } + if (picker !== undefined) { + if (!isObject(picker)) { + throw new Error(`${context}.${name}.picker must be an object if provided`); + } + const { provider, resource } = picker; + if (typeof provider !== 'string' || !provider.trim()) { + throw new Error(`${context}.${name}.picker.provider must be a non-empty string`); + } + if (typeof resource !== 'string' || !resource.trim()) { + throw new Error(`${context}.${name}.picker.resource must be a non-empty string`); + } + parsed.picker = { provider, resource }; + } out[name] = parsed; } diff --git a/packages/persona-kit/src/types.ts b/packages/persona-kit/src/types.ts index 0ab95f9f..073106f5 100644 --- a/packages/persona-kit/src/types.ts +++ b/packages/persona-kit/src/types.ts @@ -88,6 +88,32 @@ export interface PersonaInputSpec { * misconfigured launches surface loudly. */ optional?: boolean; + /** + * Declares that this input's value is an id chosen from a live list backed + * by an integration connection (e.g. a Slack user id, a Linear team id). + * When set, the deploy CLI offers the operator a picker right after the + * provider's OAuth connect — fetching the options through the cloud and + * writing the chosen id into this input — instead of making them paste a + * raw id. Purely an onboarding affordance: the runtime still resolves the + * value the usual way (explicit value → env → default), so a `picker` can + * coexist with `env`/`default`/`optional`. + */ + picker?: PersonaInputPicker; +} + +/** + * How the deploy CLI sources a picker's options for a {@link PersonaInputSpec}. + * `provider` must be one the persona declares under `integrations` (so it is + * connected before the picker runs). `resource` names what to list from that + * provider — known values today are `users`, `channels` (Slack), and `teams` + * (Linear); the value is passed through to the cloud, so new resources don't + * require a persona-kit release. + */ +export interface PersonaInputPicker { + /** Integration provider whose connection backs the option list (e.g. `slack`, `linear`). */ + provider: string; + /** Resource to list from that provider (e.g. `users`, `channels`, `teams`). */ + resource: string; } /** From 5649f851dc2c2bf873e9cc3a7cc4035d5cec779d Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Wed, 27 May 2026 19:29:44 +0200 Subject: [PATCH 2/4] feat(deploy): onboarding pickers for picker-annotated inputs After integrations connect, the orchestrator now turns each picker-annotated persona input the operator hasn't set into a choose-from-a-list prompt, then forwards the chosen value to the deployment like any --input. - collectPickerInputs(): walks picker inputs, skips ones already set (via --input or env) or whose provider wasn't connected, and degrades gracefully (warn + leave unset) on empty lists, lookup errors, or --no-prompt. - relayfileOptionsResolver(): cloud-backed list() hitting /api/v1/workspaces/:ws/integrations/:provider/options/:resource, wired as the default in cloud mode; override via resolvers.integrationOptions. - DeployIO.select() is optional; falls back to a numbered prompt (which also accepts a pasted raw value), so existing IOs keep working. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/deploy/src/connect.test.ts | 197 +++++++++++++++++++++++++++- packages/deploy/src/connect.ts | 154 ++++++++++++++++++++++ packages/deploy/src/deploy.ts | 36 ++++- packages/deploy/src/index.ts | 5 + packages/deploy/src/types.ts | 9 ++ 5 files changed, 399 insertions(+), 2 deletions(-) diff --git a/packages/deploy/src/connect.test.ts b/packages/deploy/src/connect.test.ts index 08b828d5..a49e2064 100644 --- a/packages/deploy/src/connect.test.ts +++ b/packages/deploy/src/connect.test.ts @@ -1,9 +1,14 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import type { PersonaSpec } from '@agentworkforce/persona-kit'; import { + collectPickerInputs, connectIntegrations, relayfileCatalogConfigKeyResolver, - relayfileIntegrationResolver + relayfileIntegrationResolver, + relayfileOptionsResolver, + type IntegrationOptionsResolver, + type PickerOption } from './connect.js'; import { createBufferedIO } from './io.js'; @@ -684,3 +689,193 @@ test('connectIntegrations honors --no-prompt for subscription provider setup', a assert.equal(confirmCalled, false); assert.equal(subscriptionConnectCalled, false); }); + +// --- onboarding pickers ------------------------------------------------------ + +function personaWithBenjaminPicker(): PersonaSpec { + return { + inputs: { + BENJAMIN: { + description: 'Who to DM', + env: 'BENJAMIN', + optional: true, + picker: { provider: 'slack', resource: 'users' } + } + } + } as unknown as PersonaSpec; +} + +function fakeOptionsResolver( + options: PickerOption[], + calls: Array<{ provider: string; resource: string }> +): IntegrationOptionsResolver { + return { + async list({ provider, resource }) { + calls.push({ provider, resource }); + return options; + } + }; +} + +test('collectPickerInputs prompts for an unset picker input and records the pick', async () => { + const io = createBufferedIO(); + io.scriptAnswers(['2']); // numbered-prompt fallback: choose the 2nd option + const calls: Array<{ provider: string; resource: string }> = []; + const resolver = fakeOptionsResolver( + [ + { value: 'U1', label: 'Benjamin', hint: 'ben@watchdog.no' }, + { value: 'U2', label: 'Amy' } + ], + calls + ); + + const resolved = await collectPickerInputs({ + persona: personaWithBenjaminPicker(), + workspace: 'ws-1', + io, + resolver, + inputs: {}, + connectedProviders: ['slack'], + env: {} + }); + + assert.equal(resolved.BENJAMIN, 'U2'); + assert.deepEqual(calls, [{ provider: 'slack', resource: 'users' }]); +}); + +test('collectPickerInputs leaves an already-provided input untouched', async () => { + const io = createBufferedIO(); + const calls: Array<{ provider: string; resource: string }> = []; + const resolver = fakeOptionsResolver([{ value: 'U9', label: 'Nope' }], calls); + + // value present via --input + const fromInput = await collectPickerInputs({ + persona: personaWithBenjaminPicker(), + workspace: 'ws-1', + io, + resolver, + inputs: { BENJAMIN: 'U7' }, + connectedProviders: ['slack'], + env: {} + }); + assert.equal(fromInput.BENJAMIN, 'U7'); + + // value present via env + const fromEnv = await collectPickerInputs({ + persona: personaWithBenjaminPicker(), + workspace: 'ws-1', + io, + resolver, + inputs: {}, + connectedProviders: ['slack'], + env: { BENJAMIN: 'U8' } + }); + assert.equal(fromEnv.BENJAMIN, undefined); // not chosen; runtime resolves from env + + assert.equal(calls.length, 0); // resolver never consulted when a value exists +}); + +test('collectPickerInputs skips when the provider was not connected', async () => { + const io = createBufferedIO(); + const calls: Array<{ provider: string; resource: string }> = []; + const resolver = fakeOptionsResolver([{ value: 'U1', label: 'Benjamin' }], calls); + + const resolved = await collectPickerInputs({ + persona: personaWithBenjaminPicker(), + workspace: 'ws-1', + io, + resolver, + inputs: {}, + connectedProviders: [], // slack not connected this run + env: {} + }); + + assert.equal(resolved.BENJAMIN, undefined); + assert.equal(calls.length, 0); +}); + +test('collectPickerInputs warns and skips when no options are available', async () => { + const io = createBufferedIO(); + const calls: Array<{ provider: string; resource: string }> = []; + const resolver = fakeOptionsResolver([], calls); + + const resolved = await collectPickerInputs({ + persona: personaWithBenjaminPicker(), + workspace: 'ws-1', + io, + resolver, + inputs: {}, + connectedProviders: ['slack'], + env: {} + }); + + assert.equal(resolved.BENJAMIN, undefined); + assert.ok(io.messages.some((m) => m.level === 'warn' && /no slack users available/.test(m.message))); +}); + +test('collectPickerInputs warns and skips when the lookup throws', async () => { + const io = createBufferedIO(); + const resolver: IntegrationOptionsResolver = { + async list() { + throw new Error('boom'); + } + }; + + const resolved = await collectPickerInputs({ + persona: personaWithBenjaminPicker(), + workspace: 'ws-1', + io, + resolver, + inputs: {}, + connectedProviders: ['slack'], + env: {} + }); + + assert.equal(resolved.BENJAMIN, undefined); + assert.ok(io.messages.some((m) => m.level === 'warn' && /boom/.test(m.message))); +}); + +test('collectPickerInputs does nothing under noPrompt', async () => { + const io = createBufferedIO(); + const calls: Array<{ provider: string; resource: string }> = []; + const resolver = fakeOptionsResolver([{ value: 'U1', label: 'Benjamin' }], calls); + + const resolved = await collectPickerInputs({ + persona: personaWithBenjaminPicker(), + workspace: 'ws-1', + io, + resolver, + inputs: {}, + connectedProviders: ['slack'], + env: {}, + noPrompt: true + }); + + assert.equal(resolved.BENJAMIN, undefined); + assert.equal(calls.length, 0); +}); + +test('relayfileOptionsResolver normalizes the cloud options response', async () => { + const urls: string[] = []; + const resolver = relayfileOptionsResolver({ + apiUrl: 'https://cloud.example.test', + workspaceToken: 'tok', + fetch: async (url) => { + urls.push(String(url)); + return okJson({ + ok: true, + options: [ + { value: 'team-1', label: 'Engineering', hint: 'ENG' }, + { value: '', label: 'skip-me' }, + { label: 'no-value' } + ] + }); + } + }); + + const options = await resolver.list({ workspace: 'ws 1', provider: 'linear', resource: 'teams' }); + assert.deepEqual(options, [{ value: 'team-1', label: 'Engineering', hint: 'ENG' }]); + assert.deepEqual(urls, [ + 'https://cloud.example.test/api/v1/workspaces/ws%201/integrations/linear/options/teams' + ]); +}); diff --git a/packages/deploy/src/connect.ts b/packages/deploy/src/connect.ts index 0eb57d69..8f4a5503 100644 --- a/packages/deploy/src/connect.ts +++ b/packages/deploy/src/connect.ts @@ -764,3 +764,157 @@ function openBrowser(url: string): void { }); child.unref(); } + +// --- Onboarding pickers ------------------------------------------------------ +// Inputs annotated with `picker: { provider, resource }` in the persona name a +// value the operator should *choose* (a Slack user, a Linear team, …) rather +// than paste. After the provider is connected, the orchestrator fetches the +// candidate list from the cloud and prompts. This is a pure convenience layer: +// a picked value lands in the same `inputs` map an explicit `--input` would, so +// everything downstream is unchanged, and the step degrades gracefully (skip + +// warn) whenever it can't run — no value, no prompt, or an offline lookup. + +/** One selectable candidate behind a {@link PersonaInputSpec.picker}. */ +export interface PickerOption { + value: string; + label: string; + hint?: string; +} + +/** Resolves the candidate list for a persona input's `picker`. */ +export interface IntegrationOptionsResolver { + list(args: { workspace: string; provider: string; resource: string }): Promise; +} + +/** + * Cloud-backed options resolver: `GET /api/v1/workspaces//integrations//options/`. + * The cloud triggers the provider's Nango `list-*` action and returns a + * normalized `{ options: [{ value, label, hint? }] }` body. Mirrors + * {@link relayfileCatalogConfigKeyResolver}'s auth + transport. + */ +export function relayfileOptionsResolver(opts: { + apiUrl: string; + workspaceToken: string | (() => string | Promise); + fetch?: typeof fetch; +}): IntegrationOptionsResolver { + const fetchImpl = opts.fetch ?? fetch; + const apiUrl = opts.apiUrl.replace(/\/+$/, ''); + return { + async list({ workspace, provider, resource }) { + const token = await resolveWorkspaceToken(opts.workspaceToken); + const url = + `${apiUrl}/api/v1/workspaces/${encodeURIComponent(workspace)}` + + `/integrations/${encodeURIComponent(provider)}/options/${encodeURIComponent(resource)}`; + const body = await requestJson(fetchImpl, url, token); + const raw = body && typeof body === 'object' ? (body as { options?: unknown }).options : undefined; + if (!Array.isArray(raw)) return []; + const options: PickerOption[] = []; + for (const entry of raw) { + const value = readString(entry, 'value'); + if (!value) continue; + const label = readString(entry, 'label') ?? value; + const hint = readString(entry, 'hint'); + options.push({ value, label, ...(hint ? { hint } : {}) }); + } + return options; + } + }; +} + +export interface CollectPickerInputsInput { + persona: PersonaSpec; + workspace: string; + io: DeployIO; + resolver: IntegrationOptionsResolver; + /** Inputs resolved so far (e.g. from `--input`). Not mutated. */ + inputs: Record; + /** Providers that were just connected — pickers for others are skipped. */ + connectedProviders: string[]; + env?: NodeJS.ProcessEnv; + /** When true, never prompt; picker-annotated inputs are left to resolve normally. */ + noPrompt?: boolean; +} + +/** + * Walk the persona's picker-annotated inputs and, for any without a value yet, + * prompt the operator to choose one. Returns a new inputs map (the original is + * left untouched). Every failure mode is non-fatal: the input is simply left + * unset so the runtime resolves it the usual way (env → default) or fails loudly + * later, and the operator can always fall back to `--input NAME=…`. + */ +export async function collectPickerInputs(input: CollectPickerInputsInput): Promise> { + const resolved: Record = { ...input.inputs }; + const declared = input.persona.inputs ?? {}; + const env = input.env ?? process.env; + const connected = new Set(input.connectedProviders); + + for (const [name, spec] of Object.entries(declared)) { + const picker = spec.picker; + if (!picker) continue; + + // Already have a value? An explicit --input or a set env var wins; never + // override what the operator already chose. + const envName = spec.env ?? name; + const existing = resolved[name] ?? (env[envName] ?? undefined); + if (existing !== undefined && existing.trim() !== '') continue; + + if (input.noPrompt) continue; + if (!connected.has(picker.provider)) { + // The provider wasn't connected this run (declared elsewhere, env path, + // or skipped) — we can't reliably list it, so leave the input alone. + continue; + } + + let options: PickerOption[]; + try { + options = await input.resolver.list({ + workspace: input.workspace, + provider: picker.provider, + resource: picker.resource + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + input.io.warn( + `could not list ${picker.provider} ${picker.resource} for input ${name} (${message}); pass --input ${name}=… to set it` + ); + continue; + } + + if (options.length === 0) { + input.io.warn( + `no ${picker.provider} ${picker.resource} available for input ${name}; pass --input ${name}=… to set it` + ); + continue; + } + + const label = spec.description ? `${name} — ${spec.description}` : name; + const chosen = await selectOption(input.io, `Select ${label}`, options); + if (chosen) resolved[name] = chosen; + } + + return resolved; +} + +/** + * Render a chooser for `options` and return the picked value. Uses the IO's + * native `select` when available (rich CLIs); otherwise falls back to a + * numbered prompt that also accepts a pasted raw value. + */ +async function selectOption(io: DeployIO, question: string, options: PickerOption[]): Promise { + if (io.select) { + return io.select(question, options); + } + io.info(`${question}:`); + options.forEach((option, index) => { + const hint = option.hint ? ` — ${option.hint}` : ''; + io.info(` ${index + 1}) ${option.label}${hint} [${option.value}]`); + }); + const answer = (await io.prompt(`Enter 1-${options.length} (or paste a value)`, { defaultValue: '1' })).trim(); + if (answer === '') return options[0]?.value; + const index = Number.parseInt(answer, 10); + if (Number.isInteger(index) && index >= 1 && index <= options.length) { + return options[index - 1]?.value; + } + // Not a valid index — treat the answer as a directly-pasted value. + return answer; +} diff --git a/packages/deploy/src/deploy.ts b/packages/deploy/src/deploy.ts index 532b6641..272bb1e4 100644 --- a/packages/deploy/src/deploy.ts +++ b/packages/deploy/src/deploy.ts @@ -4,13 +4,16 @@ import { defaultApiUrl } from '@agent-relay/cloud'; import { bundleStager } from './bundle.js'; import { resolveCloudUrl } from './cloud-url.js'; import { + collectPickerInputs, connectIntegrations, envIntegrationResolver, relayfileCatalogConfigKeyResolver, relayfileIntegrationResolver, + relayfileOptionsResolver, type ConnectAllInput, type IntegrationAuthRecoveryResolver, type IntegrationConnectResolver, + type IntegrationOptionsResolver, type ProviderConfigKeyResolver, type ProviderSubscriptionResolver } from './connect.js'; @@ -47,6 +50,12 @@ export interface DeployResolvers { authRecovery?: CloudAuthRecoveryResolver; subscription?: ProviderSubscriptionResolver; providerConfigKeys?: ProviderConfigKeyResolver; + /** + * Resolves candidate lists for picker-annotated inputs. Defaults to a + * cloud-backed resolver in `cloud` mode; supply your own (or a fake) to + * drive the onboarding pickers in tests or non-cloud flows. + */ + integrationOptions?: IntegrationOptionsResolver; bundle?: BundleStager; modes?: Partial>; } @@ -223,6 +232,31 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { : {}) }); + // Onboarding pickers: turn picker-annotated inputs the operator hasn't set + // into a choose-from-a-list prompt, now that the backing integrations are + // connected. Cloud mode gets a cloud-backed resolver by default; callers can + // inject their own (or a fake for tests) via `resolvers.integrationOptions`. + let resolvedInputs: Record = { ...(opts.inputs ?? {}) }; + const optionsResolver = + resolvers.integrationOptions ?? + (mode === 'cloud' + ? relayfileOptionsResolver({ + apiUrl: normalizeCloudUrl(cloudUrl ?? defaultApiUrl()), + workspaceToken: () => activeToken + }) + : undefined); + if (optionsResolver && opts.noPrompt !== true && (preflight.persona.inputs !== undefined)) { + resolvedInputs = await collectPickerInputs({ + persona: preflight.persona, + workspace, + io, + resolver: optionsResolver, + inputs: resolvedInputs, + connectedProviders: connectedIntegrations, + ...(opts.noPrompt ? { noPrompt: true } : {}) + }); + } + const bundleDir = path.resolve( path.join(preflight.personaDir, '.workforce', 'build', preflight.persona.id) ); @@ -250,7 +284,7 @@ export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = { ...(opts.harnessSource ? { harnessSource: opts.harnessSource } : {}), ...(opts.byokKey ? { byokKey: opts.byokKey } : {}), ...(opts.onExists ? { onExists: opts.onExists } : {}), - ...(opts.inputs ? { inputs: opts.inputs } : {}), + ...(Object.keys(resolvedInputs).length > 0 ? { inputs: resolvedInputs } : {}), ...(opts.onLog ? { onLog: opts.onLog } : {}) }); io.info(`launched: ${mode}/${handle.id}`); diff --git a/packages/deploy/src/index.ts b/packages/deploy/src/index.ts index 2c0e3739..828dcf4e 100644 --- a/packages/deploy/src/index.ts +++ b/packages/deploy/src/index.ts @@ -18,14 +18,19 @@ import type { export { pickMode, type CloudAuthRecoveryResolver, type DeployResolvers }; export { preflightPersona }; export { + collectPickerInputs, connectIntegrations, envIntegrationResolver, relayfileCatalogConfigKeyResolver, relayfileIntegrationResolver, + relayfileOptionsResolver, + type CollectPickerInputsInput, type ConnectAllInput, type ConnectAllResult, type IntegrationAuthRecoveryResolver, type IntegrationConnectResolver, + type IntegrationOptionsResolver, + type PickerOption, type ProviderConfigKeyResolver, type ProviderSubscriptionResolver } from './connect.js'; diff --git a/packages/deploy/src/types.ts b/packages/deploy/src/types.ts index 6cea7f07..2e9378e9 100644 --- a/packages/deploy/src/types.ts +++ b/packages/deploy/src/types.ts @@ -48,6 +48,15 @@ export interface DeployIO { prompt(question: string, opts?: { defaultValue?: string }): Promise; /** Confirmation prompt; resolves to true/false. */ confirm(question: string, opts?: { defaultValue?: boolean }): Promise; + /** + * Single-choice picker; resolves to the chosen option's `value`. Optional: + * the onboarding picker falls back to a numbered `prompt` when an IO does + * not implement it, so existing IOs keep working unchanged. + */ + select?( + question: string, + options: Array<{ value: string; label: string; hint?: string }> + ): Promise; } /** The result returned by a successful `deploy(...)` call. */ From 8cc4403b0513813eae0f0a538b257b1d27fb7ad6 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Wed, 27 May 2026 19:32:07 +0200 Subject: [PATCH 3/4] feat(deploy): first-class select() on the terminal IO for pickers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createTerminalIO now implements DeployIO.select — a numbered chooser that defaults to the first option, re-prompts a purely-numeric out-of-range answer, and treats any non-numeric answer as a directly-pasted value. The onboarding picker uses this for a clean prompt in the CLI; IOs without select still fall back to the generic numbered prompt. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/deploy/src/io.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/deploy/src/io.ts b/packages/deploy/src/io.ts index 87414a20..d3723a14 100644 --- a/packages/deploy/src/io.ts +++ b/packages/deploy/src/io.ts @@ -41,6 +41,28 @@ export function createTerminalIO(): DeployIO { const answer = (await ask(`${question}${suffix} `)).trim().toLowerCase(); if (answer === '') return def; return answer === 'y' || answer === 'yes'; + }, + async select(question, options) { + if (options.length === 0) return ''; + process.stdout.write(`${question}:\n`); + options.forEach((option, index) => { + const hint = option.hint ? ` — ${option.hint}` : ''; + process.stdout.write(` ${index + 1}) ${option.label}${hint}\n`); + }); + // Default to the first option on empty input; accept an in-range number; + // re-prompt a purely-numeric out-of-range answer; otherwise treat the + // answer as a directly-pasted value (escape hatch for ids not listed). + for (;;) { + const answer = (await ask(`Enter 1-${options.length} (or paste a value) [1] `)).trim(); + if (answer === '') return options[0]!.value; + const index = Number.parseInt(answer, 10); + if (String(index) === answer) { + if (index >= 1 && index <= options.length) return options[index - 1]!.value; + process.stderr.write(`! ${answer} is out of range (1-${options.length})\n`); + continue; + } + return answer; + } } }; } From de33222dd217af4d044eec341a2ac0cb0728dae5 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Wed, 27 May 2026 22:39:22 +0200 Subject: [PATCH 4/4] address review: preserve picker values, fix paste parsing, warn on --no-prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - index.ts wrapLauncher: merge input.inputs (deployInternal's resolved set, incl. picker picks) with the CLI inputs instead of overwriting — picker values were dropped whenever the user also passed any --input. Add a deploy()-level regression test asserting explicit --input + a picked value both reach the launcher. (CodeRabbit, major) - connect.ts selectOption fallback: only accept a pure-decimal answer as an index (String(index) === answer), so a pasted id like "1abc" is treated as a value, matching the terminal IO's select(). (CodeRabbit) - connect.ts collectPickerInputs: warn (instead of silently skipping) when a picker input is left unset under --no-prompt. (CodeRabbit) Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/deploy/src/connect.ts | 15 ++++-- packages/deploy/src/deploy.test.ts | 85 ++++++++++++++++++++++++++++++ packages/deploy/src/index.ts | 9 +++- 3 files changed, 104 insertions(+), 5 deletions(-) diff --git a/packages/deploy/src/connect.ts b/packages/deploy/src/connect.ts index 48b47e36..4de11f27 100644 --- a/packages/deploy/src/connect.ts +++ b/packages/deploy/src/connect.ts @@ -1001,7 +1001,14 @@ export async function collectPickerInputs(input: CollectPickerInputsInput): Prom const existing = resolved[name] ?? (env[envName] ?? undefined); if (existing !== undefined && existing.trim() !== '') continue; - if (input.noPrompt) continue; + if (input.noPrompt) { + // Non-interactive run: surface why the value is unset rather than + // letting it silently fall through to env/default. + input.io.warn( + `skipping ${picker.provider} ${picker.resource} picker for input ${name} because --no-prompt is set; pass --input ${name}=… to set it` + ); + continue; + } if (!connected.has(picker.provider)) { // The provider wasn't connected this run (declared elsewhere, env path, // or skipped) — we can't reliably list it, so leave the input alone. @@ -1054,10 +1061,12 @@ async function selectOption(io: DeployIO, question: string, options: PickerOptio }); const answer = (await io.prompt(`Enter 1-${options.length} (or paste a value)`, { defaultValue: '1' })).trim(); if (answer === '') return options[0]?.value; + // Only a pure decimal string is an index — `String(index) === answer` rejects + // pasted ids like `1abc` (which parseInt would coerce to 1), matching the + // terminal IO's select(). Anything else is treated as a pasted raw value. const index = Number.parseInt(answer, 10); - if (Number.isInteger(index) && index >= 1 && index <= options.length) { + if (String(index) === answer && index >= 1 && index <= options.length) { return options[index - 1]?.value; } - // Not a valid index — treat the answer as a directly-pasted value. return answer; } diff --git a/packages/deploy/src/deploy.test.ts b/packages/deploy/src/deploy.test.ts index 915d6ae9..94fe1a6d 100644 --- a/packages/deploy/src/deploy.test.ts +++ b/packages/deploy/src/deploy.test.ts @@ -10,8 +10,10 @@ import type { BundleStager, CloudAuthRecoveryResolver, IntegrationConnectResolver, + IntegrationOptionsResolver, ModeLaunchInput, ModeLauncher, + ProviderConfigKeyResolver, WorkspaceAuth } from './index.js'; @@ -1014,3 +1016,86 @@ test('deploy: clear error when nothing resolves and noPrompt is set', async () = await cleanup(); }); + +test('deploy merges explicit --input with picker-collected values for the launcher', async () => { + // Regression: when the operator passes any --input, the public deploy() + // wrapper used to overwrite the launcher inputs with the CLI set only, + // dropping picker-collected picks. Assert both reach the launcher. + const { personaPath, cleanup } = await withTempPersona( + basePersonaJson({ + integrations: { slack: {} }, + inputs: { + EXPLICIT: { description: 'set via --input', optional: true }, + BENJAMIN: { + description: 'picked from slack users', + optional: true, + picker: { provider: 'slack', resource: 'users' } + } + } + }) + ); + const io = createBufferedIO(); + io.scriptAnswers(['1']); // numbered-prompt fallback: pick the first user + + const workspaceAuth: WorkspaceAuth = { + async resolveWorkspace() { + return { workspace: 'ws-test', token: 'tok' }; + } + }; + const integrations: IntegrationConnectResolver = { + async isConnected() { + return true; // slack already connected → picker fires + }, + async connect() { + return { connectionId: 'conn-slack' }; + } + }; + // Stub so cloud mode doesn't reach for the live catalog endpoint. + const providerConfigKeys: ProviderConfigKeyResolver = { + async resolve() { + return undefined; + } + }; + const integrationOptions: IntegrationOptionsResolver = { + async list({ provider, resource }) { + assert.equal(provider, 'slack'); + assert.equal(resource, 'users'); + return [ + { value: 'U1', label: 'Benjamin', hint: 'ben@watchdog.no' }, + { value: 'U2', label: 'Amy' } + ]; + } + }; + + let launchedInputs: Record | undefined; + try { + await deploy( + { personaPath, mode: 'cloud', io, inputs: { EXPLICIT: 'explicit-val' } }, + { + workspaceAuth, + integrations, + providerConfigKeys, + integrationOptions, + bundle: successfulBundleStager(), + modes: { + cloud: { + async launch(input: ModeLaunchInput) { + launchedInputs = input.inputs; + return { + id: 'cloud-1', + async stop() { + /* no-op */ + }, + done: Promise.resolve({ code: 0 }) + }; + } + } + } + } + ); + + assert.deepEqual(launchedInputs, { EXPLICIT: 'explicit-val', BENJAMIN: 'U1' }); + } finally { + await cleanup(); + } +}); diff --git a/packages/deploy/src/index.ts b/packages/deploy/src/index.ts index e3214cd6..4a510f7b 100644 --- a/packages/deploy/src/index.ts +++ b/packages/deploy/src/index.ts @@ -136,13 +136,18 @@ function wrapLauncher( ): ModeLauncher { return { async launch(input: ModeLaunchInput) { + // `input.inputs` carries deployInternal's resolved set — the CLI + // `--input` values plus any picker-collected picks. Merge so those picks + // survive; the closure `inputs` (CLI only) is authoritative on overlap, + // but pickers skip already-set keys so overlapping values are identical. + const mergedInputs = { ...(input.inputs ?? {}), ...inputs }; return launcher.launch({ ...input, env: { ...(input.env ?? {}), - ...toInputEnv(inputs) + ...toInputEnv(mergedInputs) }, - inputs, + inputs: mergedInputs, ...(cloudUrl ? { cloudUrl } : {}) }); }