@zapier/zapier-sdk-cli 0.48.2 → 0.49.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
1
  import inquirer from "inquirer";
2
+ import search from "@inquirer/search";
2
3
  import chalk from "chalk";
3
4
  import ora from "ora";
4
5
  import { z } from "zod";
@@ -122,16 +123,12 @@ export class SchemaParameterResolver {
122
123
  const hasValue = this.getNestedValue(providedParams, param.path) !== undefined;
123
124
  return !hasValue;
124
125
  });
125
- // Split missing resolvable params into required vs optional.
126
- // "inputs" is treated as required in interactive mode.
127
- const required = missingResolvable.filter((param) => {
128
- if (param.isRequired)
129
- return true;
130
- if (param.name === "inputs")
131
- return interactiveMode;
132
- return false;
133
- });
134
- const optional = missingResolvable.filter((param) => !required.includes(param));
126
+ // Split missing resolvable params into required vs optional based on
127
+ // schema. Optional params still get prompted in interactive mode, but
128
+ // in a second pass after the schema-required ones (and their resolver
129
+ // dependencies) are settled.
130
+ const required = missingResolvable.filter((param) => param.isRequired);
131
+ const optional = missingResolvable.filter((param) => !param.isRequired);
135
132
  if (parseResult.success &&
136
133
  required.length === 0 &&
137
134
  optional.length === 0) {
@@ -322,6 +319,290 @@ export class SchemaParameterResolver {
322
319
  throw new ZapierCliMissingParametersError(missingParams);
323
320
  }
324
321
  }
322
+ /**
323
+ * Wrap a PromptConfig.validate so internal sentinels (Symbols) bypass
324
+ * it. The resolver's validator is intended for actual user values; our
325
+ * Skip / Custom / Load-more sentinels are internal control-flow and
326
+ * should pass through. Returns `undefined` when the resolver didn't
327
+ * supply a validator (so `await search({ ...rest })` doesn't get a
328
+ * pass-through identity function).
329
+ */
330
+ wrapPromptValidate(validate) {
331
+ if (!validate)
332
+ return undefined;
333
+ return (value) => (typeof value === "symbol" ? true : validate(value));
334
+ }
335
+ /**
336
+ * Apply a PromptConfig.filter to a selected value, but only when the
337
+ * value is a real data choice (not an internal sentinel). @inquirer/search
338
+ * has no built-in filter hook, so the search-backed paths call this
339
+ * explicitly before returning.
340
+ */
341
+ applyPromptFilter(filter, value) {
342
+ if (!filter || typeof value === "symbol")
343
+ return value;
344
+ return filter(value);
345
+ }
346
+ /**
347
+ * If the resolver's PromptConfig sets a `default` value, move the
348
+ * matching choice to the front so it's the first selectable item in
349
+ * the rendered source. @inquirer/search has no built-in `default`
350
+ * option; first-selectable is what Enter picks, so reordering achieves
351
+ * the same semantics inquirer.prompt's list had natively.
352
+ *
353
+ * Returns the original array if no default is set or the default
354
+ * doesn't match any current choice.
355
+ */
356
+ reorderForDefault(matches, defaultValue) {
357
+ if (defaultValue === undefined)
358
+ return matches;
359
+ const idx = matches.findIndex((c) => c.value === defaultValue);
360
+ if (idx <= 0)
361
+ return matches;
362
+ return [matches[idx], ...matches.slice(0, idx), ...matches.slice(idx + 1)];
363
+ }
364
+ /**
365
+ * Build the disabled "if you had X capability, more results would show"
366
+ * hints for any unmet capabilities the resolver declared. Returns the
367
+ * raw hint strings — callers wrap them into choice objects with whatever
368
+ * sentinel value they prefer (disabled choices' values are inert).
369
+ */
370
+ async computeCapabilityHints(resolver, context) {
371
+ if (!resolver.requireCapabilities)
372
+ return [];
373
+ const capContext = context.sdk.context;
374
+ if (!capContext.hasCapability)
375
+ return [];
376
+ const messages = [];
377
+ for (const cap of resolver.requireCapabilities) {
378
+ const enabled = await capContext.hasCapability(cap);
379
+ if (!enabled)
380
+ messages.push(buildCapabilityMessage(cap));
381
+ }
382
+ return messages;
383
+ }
384
+ /**
385
+ * Unpack a DynamicResolver.fetch result into the three shapes the caller
386
+ * cares about: a flat items array, an optional AsyncIterator for further
387
+ * pagination, and a hasMore flag. Centralizing keeps the AsyncIterable /
388
+ * `{data, nextCursor}` / `TItem[]` discrimination in one place — both
389
+ * the main dropdown loop and the search-mode flow consume it.
390
+ *
391
+ * The function is `async` for the AsyncIterable branch only (we eagerly
392
+ * consume the first page so callers don't have to discriminate). The
393
+ * other two branches return synchronously; an explicit Promise.resolve
394
+ * is unnecessary because async automatically wraps.
395
+ *
396
+ * Note: callers in search mode intentionally drop `pageIterator` /
397
+ * `hasMore` because each search() invocation re-prompts from scratch;
398
+ * pagination only matters when the prompt is the dropdown itself.
399
+ */
400
+ async unpackFetchResult(fetchResult, promptLabel) {
401
+ if (fetchResult != null &&
402
+ typeof fetchResult === "object" &&
403
+ Symbol.asyncIterator in fetchResult) {
404
+ const pageIterator = fetchResult[Symbol.asyncIterator]();
405
+ const first = await pageIterator.next();
406
+ if (!first.done && first.value) {
407
+ return {
408
+ items: Array.isArray(first.value.data) ? first.value.data : [],
409
+ pageIterator,
410
+ hasMore: first.value.nextCursor != null,
411
+ };
412
+ }
413
+ return { items: [], pageIterator, hasMore: false };
414
+ }
415
+ if (fetchResult != null &&
416
+ typeof fetchResult === "object" &&
417
+ "data" in fetchResult) {
418
+ const page = fetchResult;
419
+ const hasMore = page.nextCursor != null;
420
+ if (hasMore) {
421
+ this.debugLog(`Resolver for ${promptLabel} has more pages but no iterator. ` +
422
+ `Use toIterable() to enable "Load more..." support.`);
423
+ }
424
+ return {
425
+ items: Array.isArray(page.data) ? page.data : [],
426
+ pageIterator: null,
427
+ hasMore,
428
+ };
429
+ }
430
+ // Anything else (including null/undefined) is treated as an empty
431
+ // list — the cast on a plain array kept downstream code happy, but
432
+ // an `Array.isArray` guard is the honest version.
433
+ return {
434
+ items: Array.isArray(fetchResult) ? fetchResult : [],
435
+ pageIterator: null,
436
+ hasMore: false,
437
+ };
438
+ }
439
+ /**
440
+ * Search-mode dynamic resolver: prompts the user for free-form text, passes
441
+ * it to fetch via `search`, and either short-circuits on a primitive return
442
+ * (exact match) or renders the results as a search-filterable dropdown.
443
+ * Empty results still render the dropdown so the user can fall through to
444
+ * "(Use 'foo' as-is)" or "(Try a different search)" rather than being
445
+ * stuck.
446
+ *
447
+ * Known limitation: pagination beyond the first page is dropped. Each
448
+ * search() invocation re-prompts from scratch and the user is expected
449
+ * to refine their query if too many results came back. If a future
450
+ * high-cardinality search resolver needs Load-more here, the
451
+ * `pageIterator` / `hasMore` from `unpackFetchResult` is what to wire in.
452
+ */
453
+ async resolveDynamicWithSearchInput({ resolver, context, promptLabel, isOptional, }) {
454
+ // Suffix the placeholder onto the message rather than passing it as
455
+ // inquirer's `default`. `inquirer`'s `input` prompt renders `default`
456
+ // as prefilled text the user has to delete before typing — so a user
457
+ // who just starts typing ends up sending `${placeholder}${typed}` as
458
+ // the search term. As a message suffix the hint is read-only.
459
+ // Fold "(optional)" and the placeholder hint into one parenthetical
460
+ // so an optional search prompt with a hint reads
461
+ // "Enter or search app (optional, e.g., 'slack'):" rather than the
462
+ // double-paren "Enter or search app (optional) (e.g., 'slack'):".
463
+ const parenParts = [];
464
+ if (isOptional)
465
+ parenParts.push("optional");
466
+ if (resolver.placeholder)
467
+ parenParts.push(resolver.placeholder);
468
+ const parens = parenParts.length > 0 ? ` (${parenParts.join(", ")})` : "";
469
+ const message = `Enter or search ${promptLabel}${parens}:`;
470
+ // Sentinels are loop-invariant — hoist so they aren't reallocated on
471
+ // every re-prompt. Identity comparisons against `selected` still work
472
+ // because the same Symbol references are reused.
473
+ const SKIP_SENTINEL = Symbol("SKIP");
474
+ const USE_AS_IS_SENTINEL = Symbol("USE_AS_IS");
475
+ const TRY_AGAIN_SENTINEL = Symbol("TRY_AGAIN");
476
+ let lastNote;
477
+ while (true) {
478
+ this.stopSpinner();
479
+ if (lastNote) {
480
+ console.log(chalk.yellow(lastNote));
481
+ lastNote = undefined;
482
+ }
483
+ const answers = await inquirer.prompt([
484
+ { type: "input", name: "search", message },
485
+ ]);
486
+ const rawInput = answers.search;
487
+ const searchInput = typeof rawInput === "string" ? rawInput.trim() : "";
488
+ if (!searchInput) {
489
+ if (isOptional)
490
+ return undefined;
491
+ lastNote = `${promptLabel} is required.`;
492
+ continue;
493
+ }
494
+ // Build params once with `search` injected; pass to both fetch and
495
+ // prompt. The public type tells search-mode resolver authors to
496
+ // model TParams as `{ search?: string; ...otherDeps }`, so their
497
+ // prompt() can rely on seeing the term too.
498
+ const searchParams = {
499
+ ...context.resolvedParams,
500
+ search: searchInput,
501
+ };
502
+ this.startSpinner();
503
+ this.debugLog(`Searching ${promptLabel} for "${searchInput}"`);
504
+ let fetchResult;
505
+ try {
506
+ fetchResult = await resolver.fetch(context.sdk, searchParams);
507
+ }
508
+ finally {
509
+ this.stopSpinner();
510
+ }
511
+ if (typeof fetchResult === "string" || typeof fetchResult === "number") {
512
+ return fetchResult;
513
+ }
514
+ const { items } = await this.unpackFetchResult(fetchResult, promptLabel);
515
+ const choicesConfig = resolver.prompt(items, searchParams);
516
+ const dataChoices = choicesConfig.choices ?? [];
517
+ const capabilityHintMessages = await this.computeCapabilityHints(resolver, context);
518
+ const selected = await search({
519
+ message: choicesConfig.message,
520
+ validate: this.wrapPromptValidate(choicesConfig.validate),
521
+ // @inquirer/search passes an AbortSignal as the second arg for
522
+ // cancelling async sources. All three of our source callbacks are
523
+ // pure-local (filter an already-fetched array), so we intentionally
524
+ // ignore the signal. A future server-side filter implementation
525
+ // would need to honor it.
526
+ source: (term) => {
527
+ const trimmed = (term ?? "").trim();
528
+ const lower = trimmed.toLowerCase();
529
+ const matches = trimmed
530
+ ? dataChoices.filter((c) => c.name.toLowerCase().includes(lower))
531
+ : dataChoices;
532
+ // @inquirer/search activates the first selectable item, so
533
+ // matches always go first — the user already typed a query at
534
+ // the text prompt to get here, and pressing Enter immediately
535
+ // should pick the top match rather than (Skip) or one of the
536
+ // escape hatches. If the resolver's PromptConfig sets a
537
+ // default, surface that match as the first selectable.
538
+ const orderedMatches = trimmed
539
+ ? matches
540
+ : this.reorderForDefault(matches, choicesConfig.default);
541
+ const skipChoice = isOptional
542
+ ? [{ name: chalk.dim("(Skip)"), value: SKIP_SENTINEL }]
543
+ : [];
544
+ // JSON.stringify handles the quoting + escape so an input like
545
+ // `foo"bar` renders as `(Use "foo\"bar" as-is)` instead of
546
+ // breaking the visible label with a stray quote.
547
+ const useAsIsChoice = {
548
+ name: chalk.dim(`(Use ${JSON.stringify(searchInput)} as-is)`),
549
+ value: USE_AS_IS_SENTINEL,
550
+ };
551
+ const tryAgainChoice = {
552
+ name: chalk.dim("(Try a different search)"),
553
+ value: TRY_AGAIN_SENTINEL,
554
+ };
555
+ const out = [];
556
+ if (orderedMatches.length === 0) {
557
+ // Empty results: the user just typed something and got
558
+ // nothing back. Pushing (Skip) first would make pressing
559
+ // Enter surprise-skip the parameter they committed to. Bias
560
+ // toward "use what I typed" and "try a different search."
561
+ out.push(useAsIsChoice);
562
+ out.push(tryAgainChoice);
563
+ out.push(...skipChoice);
564
+ }
565
+ else {
566
+ out.push(...orderedMatches);
567
+ out.push(...skipChoice);
568
+ out.push(useAsIsChoice);
569
+ out.push(tryAgainChoice);
570
+ }
571
+ for (const message of capabilityHintMessages) {
572
+ out.push({
573
+ name: chalk.dim(message),
574
+ value: SKIP_SENTINEL,
575
+ disabled: true,
576
+ });
577
+ }
578
+ return out;
579
+ },
580
+ });
581
+ if (selected === SKIP_SENTINEL)
582
+ return undefined;
583
+ // The (Use as-is) escape hatch is a real value heading into the
584
+ // same downstream slot as a picked match, so the resolver's
585
+ // validate and filter both apply. search()'s wrapped validate
586
+ // bypasses sentinels, so we have to invoke validate manually here
587
+ // with the typed string. On rejection, surface the validator's
588
+ // error as a yellow note above the next text prompt.
589
+ if (selected === USE_AS_IS_SENTINEL) {
590
+ const validationResult = choicesConfig.validate?.(searchInput);
591
+ if (validationResult === false) {
592
+ lastNote = `${promptLabel}: invalid value.`;
593
+ continue;
594
+ }
595
+ if (typeof validationResult === "string") {
596
+ lastNote = validationResult;
597
+ continue;
598
+ }
599
+ return this.applyPromptFilter(choicesConfig.filter, searchInput);
600
+ }
601
+ if (selected === TRY_AGAIN_SENTINEL)
602
+ continue;
603
+ return this.applyPromptFilter(choicesConfig.filter, selected);
604
+ }
605
+ }
325
606
  async resolveParameter(param, context, functionName, options) {
326
607
  const resolver = this.getResolver(param.name, context.sdk, functionName);
327
608
  if (!resolver) {
@@ -331,6 +612,27 @@ export class SchemaParameterResolver {
331
612
  isOptional: options?.isOptional,
332
613
  });
333
614
  }
615
+ /**
616
+ * Run a resolver to obtain a value for one parameter, prompting the
617
+ * user when necessary. Routes to one of several prompt backends and
618
+ * has to keep the `PromptConfig` contract honest across each one.
619
+ *
620
+ * `PromptConfig` field × prompt-backend handling:
621
+ *
622
+ * | field | list (search()) | checkbox/confirm (inquirer.prompt) | search-mode dropdown (search()) |
623
+ * | -------- | --------------- | ---------------------------------- | ------------------------------- |
624
+ * | type | required | required | required |
625
+ * | name | (set internally)| forwarded | (set internally) |
626
+ * | message | forwarded | forwarded | forwarded |
627
+ * | choices | filtered in src | passed to inquirer | filtered in src |
628
+ * | default | reorder matches | passed (also internal cursor jump) | reorder matches |
629
+ * | validate | wrapped | passed (inquirer native) | wrapped + manual for (Use as-is)|
630
+ * | filter | manual once | inquirer native (do NOT double) | manual once |
631
+ *
632
+ * Escape-hatch sentinels (Skip / Custom value / Use as-is / Load more
633
+ * / Try again) bypass the resolver's validate/filter because they
634
+ * aren't real user values — they're CLI control-flow.
635
+ */
334
636
  async resolveWithResolver(resolver, param, context, options = {}) {
335
637
  const { arrayIndex, isOptional } = options;
336
638
  const inArrayContext = arrayIndex != null;
@@ -376,46 +678,55 @@ export class SchemaParameterResolver {
376
678
  this.stopSpinner();
377
679
  return autoResolution.resolvedValue;
378
680
  }
681
+ if (dynamicResolver.inputType === "search") {
682
+ this.stopSpinner();
683
+ return await this.resolveDynamicWithSearchInput({
684
+ resolver: dynamicResolver,
685
+ context,
686
+ promptLabel,
687
+ isOptional: isOptional ?? false,
688
+ });
689
+ }
379
690
  this.debugLog(`Fetching options for ${promptLabel}`);
380
- const fetchResult = await dynamicResolver.fetch(context.sdk, context.resolvedParams);
381
- // Check if the result is an AsyncIterable of pages (from toIterable())
382
- let pageIterator = null;
383
- let items;
384
- let hasMore = false;
385
- if (fetchResult != null &&
386
- typeof fetchResult === "object" &&
387
- Symbol.asyncIterator in fetchResult) {
388
- // Paginated: pull the first page from the iterator
389
- pageIterator = fetchResult[Symbol.asyncIterator]();
390
- const first = await pageIterator.next();
391
- if (!first.done && first.value) {
392
- items = first.value.data;
393
- hasMore = first.value.nextCursor != null;
394
- }
395
- else {
396
- items = [];
397
- }
691
+ let fetchResult;
692
+ try {
693
+ fetchResult = await dynamicResolver.fetch(context.sdk, context.resolvedParams);
398
694
  }
399
- else if (fetchResult != null &&
400
- typeof fetchResult === "object" &&
401
- "data" in fetchResult) {
402
- const page = fetchResult;
403
- items = page.data;
404
- hasMore = page.nextCursor != null;
405
- if (hasMore) {
406
- this.debugLog(`Resolver for ${promptLabel} has more pages but no iterator. ` +
407
- `Use toIterable() to enable "Load more..." support.`);
408
- }
695
+ finally {
696
+ // Mirror the search-mode pattern: stop the spinner whether fetch
697
+ // succeeds or throws, so a network/auth error doesn't leave the
698
+ // spinner spinning through the error bubbling.
699
+ this.stopSpinner();
409
700
  }
410
- else {
411
- items = fetchResult || [];
412
- pageIterator = null;
701
+ // Primitive returns are only meaningful in `inputType: "search"`
702
+ // mode, where they signal an exact-match short-circuit. Without
703
+ // search mode there's nothing for the user to disambiguate, so a
704
+ // primitive here is a resolver bug. Warn the author loudly, then fall
705
+ // back to a manual text prompt so the user isn't stranded mid-command.
706
+ if (typeof fetchResult === "string" || typeof fetchResult === "number") {
707
+ console.error(chalk.yellow(`Resolver for ${promptLabel} returned a primitive value but is not in search mode. Set inputType: "search" on the resolver if you want exact-match short-circuit. Falling back to manual entry.`));
708
+ const fallbackAnswers = await inquirer.prompt([
709
+ {
710
+ type: "input",
711
+ name: promptName,
712
+ message: `Enter ${promptLabel}${isOptional ? " (optional)" : ""}:`,
713
+ },
714
+ ]);
715
+ const fallbackValue = fallbackAnswers[promptName];
716
+ if (isOptional &&
717
+ (fallbackValue === undefined || fallbackValue === "")) {
718
+ return undefined;
719
+ }
720
+ return fallbackValue;
413
721
  }
722
+ const unpacked = await this.unpackFetchResult(fetchResult, promptLabel);
723
+ let pageIterator = unpacked.pageIterator;
724
+ let items = unpacked.items;
725
+ let hasMore = unpacked.hasMore;
414
726
  const LOAD_MORE_SENTINEL = Symbol("LOAD_MORE");
415
727
  const SKIP_SENTINEL = Symbol("SKIP");
416
728
  const CUSTOM_VALUE_SENTINEL = Symbol("CUSTOM_VALUE");
417
729
  let newItemsStartIndex = -1;
418
- this.stopSpinner();
419
730
  while (true) {
420
731
  const promptConfig = dynamicResolver.prompt(items, context.resolvedParams);
421
732
  promptConfig.name = promptName;
@@ -428,59 +739,134 @@ export class SchemaParameterResolver {
428
739
  if (!hasSelectableChoice && !hasMore) {
429
740
  throw new ZapierCliValidationError(`No ${promptLabel} available to select.`);
430
741
  }
431
- if (isOptional && promptConfig.choices) {
432
- promptConfig.choices.unshift({
433
- name: chalk.dim("(Skip)"),
434
- value: SKIP_SENTINEL,
435
- });
436
- }
437
- // Pinned to the top so it's reachable without scrolling past a long
438
- // list. Restricted to single-select: a typed scalar can't merge
439
- // sensibly with checkbox selections.
440
- if (promptConfig.choices && promptConfig.type === "list") {
441
- const insertAt = isOptional ? 1 : 0;
442
- promptConfig.choices.splice(insertAt, 0, {
443
- name: chalk.dim("(Enter custom value)"),
444
- value: CUSTOM_VALUE_SENTINEL,
445
- });
742
+ // Capability hints: shown when no more pages remain and the
743
+ // resolver advertised capabilities that would expand results.
744
+ // The Skip / (Enter custom value) / (Load more...) sentinels live
745
+ // in the list/checkbox branches below since the search-backed list
746
+ // path injects them via the source callback rather than mutating
747
+ // promptConfig.choices.
748
+ const capabilityHints = [];
749
+ if (!hasMore) {
750
+ const hintMessages = await this.computeCapabilityHints(dynamicResolver, context);
751
+ for (const message of hintMessages) {
752
+ capabilityHints.push({
753
+ name: chalk.dim(message),
754
+ value: SKIP_SENTINEL,
755
+ disabled: true,
756
+ });
757
+ }
446
758
  }
447
- // Add "Load more..." option if paginated
448
- if (hasMore && pageIterator && promptConfig.choices) {
449
- promptConfig.choices.push({
450
- name: chalk.dim("(Load more...)"),
451
- value: LOAD_MORE_SENTINEL,
759
+ let selected;
760
+ if (promptConfig.type === "list") {
761
+ // Single-select dropdowns go through @inquirer/search so users
762
+ // can type-to-filter long lists. (Load more...) sits as a trailer
763
+ // and is reachable whether or not the search yielded matches; the
764
+ // user retypes after loading because @inquirer/search can't seed
765
+ // its input box.
766
+ const dataChoices = promptConfig.choices ?? [];
767
+ selected = await search({
768
+ message: promptConfig.message,
769
+ validate: this.wrapPromptValidate(promptConfig.validate),
770
+ source: (term) => {
771
+ const trimmed = (term ?? "").trim();
772
+ const lower = trimmed.toLowerCase();
773
+ const matches = trimmed
774
+ ? dataChoices.filter((c) => c.name.toLowerCase().includes(lower))
775
+ : dataChoices;
776
+ const out = [];
777
+ const skipChoice = isOptional
778
+ ? [
779
+ {
780
+ name: chalk.dim("(Skip)"),
781
+ value: SKIP_SENTINEL,
782
+ },
783
+ ]
784
+ : [];
785
+ const customValueChoice = {
786
+ name: chalk.dim("(Enter custom value)"),
787
+ value: CUSTOM_VALUE_SENTINEL,
788
+ };
789
+ // @inquirer/search activates the first selectable item.
790
+ // Three cases, with one unifying rule: never put (Skip)
791
+ // first when the user is committed (typed a filter,
792
+ // default specified) — pressing Enter then surprise-skips.
793
+ // - Has matches + (filtered or defaulted) → matches first,
794
+ // then Skip and Custom, then Load more as a trailer.
795
+ // - No matches + filtered → Custom and Load more above
796
+ // Skip, so every constructive option beats out skipping.
797
+ // - Otherwise (browsing) → Skip first (the conventional
798
+ // optional-dropdown UX), then Custom, matches, Load more.
799
+ const orderedMatches = trimmed
800
+ ? matches
801
+ : this.reorderForDefault(matches, promptConfig.default);
802
+ const matchesFirst = trimmed || promptConfig.default !== undefined;
803
+ const loadMoreChoice = hasMore && pageIterator
804
+ ? {
805
+ name: chalk.dim("(Load more...)"),
806
+ value: LOAD_MORE_SENTINEL,
807
+ }
808
+ : null;
809
+ if (matchesFirst && orderedMatches.length > 0) {
810
+ out.push(...orderedMatches);
811
+ out.push(...skipChoice);
812
+ out.push(customValueChoice);
813
+ if (loadMoreChoice)
814
+ out.push(loadMoreChoice);
815
+ }
816
+ else if (matchesFirst) {
817
+ out.push(customValueChoice);
818
+ if (loadMoreChoice)
819
+ out.push(loadMoreChoice);
820
+ out.push(...skipChoice);
821
+ }
822
+ else {
823
+ out.push(...skipChoice);
824
+ out.push(customValueChoice);
825
+ out.push(...orderedMatches);
826
+ if (loadMoreChoice)
827
+ out.push(loadMoreChoice);
828
+ }
829
+ // Capability hints only render when there are actual matches
830
+ // (or no search term) so an empty-search view stays focused.
831
+ if (capabilityHints.length > 0 &&
832
+ (!trimmed || matches.length > 0)) {
833
+ out.push(...capabilityHints);
834
+ }
835
+ return out;
836
+ },
452
837
  });
453
838
  }
454
- // Show hints for capabilities that would expand results
455
- if (!hasMore &&
456
- promptConfig.choices &&
457
- dynamicResolver.requireCapabilities) {
458
- const capContext = context.sdk.context;
459
- if (capContext.hasCapability) {
460
- for (const cap of dynamicResolver.requireCapabilities) {
461
- const enabled = await capContext.hasCapability(cap);
462
- if (!enabled) {
463
- promptConfig.choices.push({
464
- name: chalk.dim(buildCapabilityMessage(cap)),
465
- value: SKIP_SENTINEL,
466
- disabled: true,
467
- });
468
- }
469
- }
839
+ else {
840
+ // Multi-select (checkbox) and confirm prompts stay on
841
+ // inquirer.prompt — search is single-select.
842
+ if (isOptional && promptConfig.choices) {
843
+ promptConfig.choices.unshift({
844
+ name: chalk.dim("(Skip)"),
845
+ value: SKIP_SENTINEL,
846
+ });
470
847
  }
471
- }
472
- // After loading more, jump cursor to first new item.
473
- // Offset by the number of injected choices before the data items (e.g. Skip).
474
- if (newItemsStartIndex >= 0 && promptConfig.choices) {
475
- const injectedBefore = (isOptional ? 1 : 0) + (promptConfig.type === "list" ? 1 : 0);
476
- const adjustedIndex = newItemsStartIndex + injectedBefore;
477
- if (promptConfig.choices[adjustedIndex]) {
478
- promptConfig.default = promptConfig.choices[adjustedIndex].value;
848
+ if (hasMore && pageIterator && promptConfig.choices) {
849
+ promptConfig.choices.push({
850
+ name: chalk.dim("(Load more...)"),
851
+ value: LOAD_MORE_SENTINEL,
852
+ });
853
+ }
854
+ if (capabilityHints.length > 0 && promptConfig.choices) {
855
+ promptConfig.choices.push(...capabilityHints);
856
+ }
857
+ // After loading more, jump cursor to first new item.
858
+ // Offset by the number of injected choices before the data items (e.g. Skip).
859
+ if (newItemsStartIndex >= 0 && promptConfig.choices) {
860
+ const injectedBefore = isOptional ? 1 : 0;
861
+ const adjustedIndex = newItemsStartIndex + injectedBefore;
862
+ if (promptConfig.choices[adjustedIndex]) {
863
+ promptConfig.default = promptConfig.choices[adjustedIndex].value;
864
+ }
865
+ newItemsStartIndex = -1;
479
866
  }
480
- newItemsStartIndex = -1;
867
+ const answers = await inquirer.prompt([promptConfig]);
868
+ selected = answers[promptName];
481
869
  }
482
- const answers = await inquirer.prompt([promptConfig]);
483
- let selected = answers[promptName];
484
870
  if (selected === SKIP_SENTINEL) {
485
871
  return undefined;
486
872
  }
@@ -520,7 +906,14 @@ export class SchemaParameterResolver {
520
906
  }
521
907
  continue;
522
908
  }
523
- return selected;
909
+ // Only the search-backed list path needs a manual filter call —
910
+ // @inquirer/search has no built-in filter hook. inquirer.prompt
911
+ // (the checkbox / confirm branch above) already applies the
912
+ // resolver's filter natively, so re-applying here would
913
+ // double-filter non-idempotent values.
914
+ return promptConfig.type === "list"
915
+ ? this.applyPromptFilter(promptConfig.filter, selected)
916
+ : selected;
524
917
  }
525
918
  }
526
919
  else if (resolver.type === "fields") {
@@ -822,74 +1215,122 @@ export class SchemaParameterResolver {
822
1215
  }
823
1216
  }
824
1217
  /**
825
- * Prompt user with choices (handles both single and multi-select with pagination)
1218
+ * Prompt user with choices (handles both single and multi-select with pagination).
1219
+ * Single-select goes through @inquirer/search so users can type-to-filter long
1220
+ * dropdowns (SELECT fields); multi-select stays on inquirer.prompt since search
1221
+ * is single-select only.
826
1222
  */
827
1223
  async promptWithChoices({ fieldMeta, choices: initialChoices, nextCursor: initialCursor, inputs, context, }) {
828
1224
  this.stopSpinner();
829
1225
  const choices = [...initialChoices];
830
1226
  let nextCursor = initialCursor;
831
1227
  const LOAD_MORE_SENTINEL = Symbol("LOAD_MORE");
1228
+ const SKIP_SENTINEL = Symbol("SKIP");
832
1229
  const CUSTOM_VALUE_SENTINEL = Symbol("CUSTOM_VALUE");
833
- // Progressive loading loop
1230
+ const message = `${fieldMeta.title}${fieldMeta.isRequired ? " (required)" : " (optional)"}:`;
834
1231
  while (true) {
835
- const promptChoices = choices.map((choice) => ({
836
- name: choice.label,
837
- value: choice.value,
838
- }));
839
- // Pin "(Enter custom value)" to the top for single-select fields so
840
- // users can supply values not in the fetched dropdown (e.g. dynamic
841
- // dropdowns that don't return every valid value).
1232
+ let selectedValue;
842
1233
  if (!fieldMeta.isMultiSelect) {
843
- promptChoices.unshift({
844
- name: chalk.dim("(Enter custom value)"),
845
- value: CUSTOM_VALUE_SENTINEL,
846
- });
847
- }
848
- // Add "(Load more...)" option if there are more pages
849
- if (nextCursor) {
850
- promptChoices.push({
851
- name: chalk.dim("(Load more...)"),
852
- value: LOAD_MORE_SENTINEL,
1234
+ const dataChoices = choices.map((c) => ({
1235
+ name: c.label,
1236
+ value: c.value,
1237
+ }));
1238
+ selectedValue = await search({
1239
+ message,
1240
+ source: (term) => {
1241
+ const trimmed = (term ?? "").trim();
1242
+ const lower = trimmed.toLowerCase();
1243
+ const matches = trimmed
1244
+ ? dataChoices.filter((c) => c.name.toLowerCase().includes(lower))
1245
+ : dataChoices;
1246
+ const out = [];
1247
+ const skipChoice = !fieldMeta.isRequired
1248
+ ? [{ name: chalk.dim("(Skip)"), value: SKIP_SENTINEL }]
1249
+ : [];
1250
+ // Dynamic dropdowns don't always return every valid value, so a
1251
+ // custom-value escape hatch is essential for SELECT fields.
1252
+ const customValueChoice = {
1253
+ name: chalk.dim("(Enter custom value)"),
1254
+ value: CUSTOM_VALUE_SENTINEL,
1255
+ };
1256
+ // Same ordering rule as the main dynamic-list source: never
1257
+ // let (Skip) be the first selectable when the user is
1258
+ // committed (filtered). Empty filtered results put Custom
1259
+ // and Load more above Skip so every constructive option
1260
+ // beats out skipping. Otherwise matches go first.
1261
+ const loadMoreChoice = nextCursor && context
1262
+ ? {
1263
+ name: chalk.dim("(Load more...)"),
1264
+ value: LOAD_MORE_SENTINEL,
1265
+ }
1266
+ : null;
1267
+ if (trimmed && matches.length > 0) {
1268
+ out.push(...matches);
1269
+ out.push(...skipChoice);
1270
+ out.push(customValueChoice);
1271
+ if (loadMoreChoice)
1272
+ out.push(loadMoreChoice);
1273
+ }
1274
+ else if (trimmed) {
1275
+ out.push(customValueChoice);
1276
+ if (loadMoreChoice)
1277
+ out.push(loadMoreChoice);
1278
+ out.push(...skipChoice);
1279
+ }
1280
+ else {
1281
+ out.push(...skipChoice);
1282
+ out.push(customValueChoice);
1283
+ out.push(...matches);
1284
+ if (loadMoreChoice)
1285
+ out.push(loadMoreChoice);
1286
+ }
1287
+ return out;
1288
+ },
853
1289
  });
1290
+ if (selectedValue === SKIP_SENTINEL) {
1291
+ return undefined;
1292
+ }
1293
+ if (selectedValue === CUSTOM_VALUE_SENTINEL) {
1294
+ return await this.promptFreeForm(fieldMeta);
1295
+ }
854
1296
  }
855
- // Add skip option for optional fields (single-select only)
856
- if (!fieldMeta.isRequired && !fieldMeta.isMultiSelect) {
857
- promptChoices.push({ name: "(Skip)", value: undefined });
858
- }
859
- const promptConfig = {
860
- type: fieldMeta.isMultiSelect ? "checkbox" : "list",
861
- name: fieldMeta.key,
862
- message: `${fieldMeta.title}${fieldMeta.isRequired ? " (required)" : " (optional)"}:`,
863
- choices: promptChoices,
864
- ...(fieldMeta.isMultiSelect && {
1297
+ else {
1298
+ const promptChoices = choices.map((c) => ({
1299
+ name: c.label,
1300
+ value: c.value,
1301
+ }));
1302
+ if (nextCursor) {
1303
+ promptChoices.push({
1304
+ name: chalk.dim("(Load more...)"),
1305
+ value: LOAD_MORE_SENTINEL,
1306
+ });
1307
+ }
1308
+ const promptConfig = {
1309
+ type: "checkbox",
1310
+ name: fieldMeta.key,
1311
+ message,
1312
+ choices: promptChoices,
865
1313
  validate: (input) => {
866
1314
  if (fieldMeta.isRequired && (!input || input.length === 0)) {
867
1315
  return "At least one selection is required";
868
1316
  }
869
1317
  return true;
870
1318
  },
871
- }),
872
- };
873
- const answer = await inquirer.prompt([promptConfig]);
874
- let selectedValue = answer[fieldMeta.key];
875
- if (selectedValue === CUSTOM_VALUE_SENTINEL) {
876
- return await this.promptFreeForm(fieldMeta);
1319
+ };
1320
+ const answer = await inquirer.prompt([promptConfig]);
1321
+ selectedValue = answer[fieldMeta.key];
877
1322
  }
878
- // Check if user selected "Load more..."
879
1323
  const wantsMore = fieldMeta.isMultiSelect
880
1324
  ? Array.isArray(selectedValue) &&
881
1325
  selectedValue.includes(LOAD_MORE_SENTINEL)
882
1326
  : selectedValue === LOAD_MORE_SENTINEL;
883
1327
  if (wantsMore && nextCursor && context) {
884
- // Remove sentinel from multi-select
885
1328
  if (fieldMeta.isMultiSelect && Array.isArray(selectedValue)) {
886
1329
  selectedValue = selectedValue.filter((v) => v !== LOAD_MORE_SENTINEL);
887
1330
  }
888
- // Fetch next page
889
1331
  const result = await this.fetchChoices(fieldMeta, inputs, context, nextCursor);
890
1332
  choices.push(...result.choices);
891
1333
  nextCursor = result.nextCursor;
892
- // Re-prompt with updated choices
893
1334
  continue;
894
1335
  }
895
1336
  return selectedValue;