@zapier/zapier-sdk-cli 0.48.1 → 0.49.0

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