sap-ai-dev-toolkit 0.9.6 → 0.9.8

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.
package/README.md CHANGED
@@ -300,7 +300,7 @@ The installer handles VSP provisioning automatically:
300
300
  - Uses the package's bundled patched VSP binary for the current platform, verified against the npm-published `dist/checksums.txt`. When the bundled asset is missing (an interrupted install, an antivirus quarantine, or a git checkout without `dist/`), a checksum-anchored download from the matching GitHub release is the fallback; MCP server startup runs it too, because npx entries skip install scripts.
301
301
  - No Go toolchain is downloaded or required at install time; Go is only used by the repository's own `build:vsp` development script.
302
302
 
303
- Setup detects where it is running. With `H2O_URL` set, it treats the environment as SAP Business Application Studio and opens a checkbox picker for BAS destinations, with no destinations selected by default. Use **Space** to choose destinations and **Enter** to confirm. Press **a** to toggle all destinations (select all if any are unchecked; otherwise clear the selection). Confirming with none selected removes this add-on's managed MCP entries. When the `cf` CLI 8.18 or newer is authenticated to a targeted space, setup first offers an optional import from that space's Destination service; type **y** then **Enter** to include it, or press **Enter** to skip. Accepted CF and BAS destinations appear together in the picker.
303
+ Setup detects where it is running. With `H2O_URL` set, it treats the environment as SAP Business Application Studio and opens a checkbox picker for BAS destinations, with no destinations selected by default: every row starts with an empty box `[ ]`. Use **Space** to tick a destination (`[✓]`) and **Enter** to confirm; the line under the list always names the next key to press. Press **a** to toggle all destinations (select all if any are unchecked; otherwise clear the selection). Pressing **Enter** with nothing selected asks for a second **Enter**, because confirming with none selected removes this add-on's managed MCP entries. When the `cf` CLI 8.18 or newer is authenticated to a targeted space, setup first offers an optional import from that space's Destination service; type **y** then **Enter** to include it, or press **Enter** to skip. Accepted CF and BAS destinations appear together in the picker.
304
304
 
305
305
  Without `H2O_URL`, interactive setup looks for local SAP GUI system configuration on macOS and Windows (for example SAP GUI landscape XML and `saplogon.ini`). SAP GUI landscape files describe SAP GUI/DIAG connectivity, not the ADT HTTP(S) endpoint or a complete ABAP client catalog, so setup asks for the SAP client or comma-separated clients to configure for each selected system (for example `100,200`), proposes an ADT URL from the SAP GUI host and instance when it can, and asks you to confirm or edit that value. Multiple entered clients create separate isolated MCP entries, one per system/client pair. Local SAP GUI MCP server keys use the SAP system ID and client (for example `t4d-100`) instead of the longer SAP GUI description, while `SAP_AI_DEV_TOOLKIT_DESTINATION` still preserves the display name. When the ADT URL uses an IP address, setup automatically inspects the SAP HTTPS certificate, extracts DNS subjectAltName entries, and offers to enable secure TLS self-healing with a simple yes/no confirmation; you do not need to type certificate names. The generated MCP entry uses VS Code input prompts for the SAP user and password at server start; raw SAP credentials are not stored in `mcp.json`. On Windows, setup offers Windows / smart card SSO (the default) instead, which writes `SAP_AUTH_MODE=sso` without username/password prompts. At server start, VSP opens Microsoft Edge on the ADT URL; Edge completes the login the same way it does in your browser (smart card certificate and PIN, Kerberos, or company SAML/Entra login), and the session is cached and refreshed automatically. SSO entries need an ADT URL with a host name, not an IP address. npm may run its install hook without an interactive terminal, even when the shell is interactive; in that case, destination selection is skipped without changing MCP config, and you should run `sap-ai-dev --setup` afterwards.
306
306
 
@@ -447,7 +447,7 @@ Review skipped or conflicting files and merge changes manually. `LintABAP` analy
447
447
  If the `cf` CLI is version 8.18 or newer and authenticated to a targeted space, setup offers an opt-in import from that space's SAP Destination service. It does not create service keys unless you accept. The imported records are limited to the current space and are merged with BAS destinations for selection. OnPremise destinations require choosing a Connectivity service instance; runtime traffic uses that service's proxy, and PrincipalPropagation uses the current CF user's token. Internet destinations use the configured HTTP(S) proxy environment. Setup stores only service-instance/key references in `mcp.json`, not the service-key credentials. At runtime the generated entry verifies the active CF space and resolves those references. Setup removes an add-on-created key only when no remaining CF entry references it and the CLI is targeted to the key's recorded space; keys are left untouched when the space cannot be verified.
448
448
  Cloud Foundry HTTP destination probes use forward-form HTTP requests, not CONNECT tunnels; SAP Connectivity requires HTTP from the application to its proxy. OnPremise requests are canceled when the client disconnects or the runtime route closes.
449
449
 
450
- Interactive npm install and `sap-ai-dev --setup` both use a checkbox picker with no destinations selected by default. Use **Space** to choose destinations, **Enter** to confirm, **a** to toggle all (select all if any are unchecked; otherwise clear the selection), and **m** to configure an undiscovered system manually in the manual entry wizard; configured systems join the picker pre-selected. Confirming with none checked removes this package's generated MCP entries. Non-interactive installs skip destination or local SAP GUI system selection without changing MCP config. Run the interactive setup command above; generated entries fall back to the pinned npx launcher automatically when the package is not installed globally.
450
+ Interactive npm install and `sap-ai-dev --setup` both use a checkbox picker with no destinations selected by default. Use **Space** to choose destinations, **Enter** to confirm, **a** to toggle all (select all if any are unchecked; otherwise clear the selection), and **m** to configure an undiscovered system manually in the manual entry wizard; configured systems join the picker pre-selected. Confirming with none checked takes a second **Enter** and removes this package's generated MCP entries. Non-interactive installs skip destination or local SAP GUI system selection without changing MCP config. Run the interactive setup command above; generated entries fall back to the pinned npx launcher automatically when the package is not installed globally.
451
451
 
452
452
  When nothing is detected — no BAS destinations, no Cloud Foundry imports, or no local SAP GUI entries — setup offers the **manual entry wizard** instead of stopping. The same wizard is available at any point in the destination picker via the **m** shortcut. It asks for a system name, the ADT HTTP(S) base URL (for example `https://myhost.example:44300`), one or more 3-digit SAP clients, and (on Windows) the login mode. You can add several systems in one run; each becomes the same isolated per-system/client MCP server the discovery flows generate, including credential input prompts and TLS self-healing for IP-based HTTPS URLs. Declining the wizard when nothing was detected exits setup without changes; aborting it inside the picker (Ctrl+C) returns to the selection unchanged.
453
453
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sap-ai-dev-toolkit",
3
- "version": "0.9.6",
3
+ "version": "0.9.8",
4
4
  "description": "SAP AI development toolkit for BAS, ABAP, RAP, CAP, HANA Cloud, Fiori, UI5, and MCP",
5
5
  "author": "Gurkan Yilmaz",
6
6
  "type": "module",
package/src/launcher.mjs CHANGED
@@ -4,7 +4,7 @@ import { readFile } from 'node:fs/promises';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { discoverDestinations, remediation, slugifyDestination, statusRows } from './bas-discovery.mjs';
7
- import { discoverSapGuiSystems } from './local-sap-gui.mjs';
7
+ import { detectAdtUrl, discoverSapGuiSystems, hasExplicitPort } from './local-sap-gui.mjs';
8
8
  import { binaryTarget, downloadBinary, findBinary } from './binary.mjs';
9
9
  import { MCPProxy } from './mcp-proxy.mjs';
10
10
  import { installMcpConfig, npxMcpLauncher, repairManagedMcpConfig } from './mcp-config.mjs';
@@ -186,6 +186,10 @@ async function binaryOrError() {
186
186
  ].join(' '));
187
187
  }
188
188
 
189
+ async function detectLocalAdtUrl(target) {
190
+ return detectAdtUrl(await binaryOrError(), { ...target, env: runtimeEnv });
191
+ }
192
+
189
193
  async function discoverForCommand() {
190
194
  const env = { ...runtimeEnv };
191
195
  if (env.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'cloud-foundry') env.SAP_AI_DEV_TOOLKIT_DESTINATION = '';
@@ -205,10 +209,25 @@ async function configuredLocalSapGuiDestination(env) {
205
209
  // Older entries used windows-sso, browser-saml or saml-password; all of them now mean browser SSO.
206
210
  if (!['', 'basic'].includes(String(env.SAP_AUTH_MODE || '').toLowerCase())) {
207
211
  const cacheKey = `${systemId || new URL(env.SAP_URL).hostname}-${client}`.toLowerCase().replace(/[^a-z0-9.-]+/g, '-');
212
+ let url = env.SAP_URL;
213
+ // SAP GUI landscapes carry no HTTP port, and the SSO browser cannot sign in on a guessed one.
214
+ if (!hasExplicitPort(url)) {
215
+ const host = new URL(url).hostname;
216
+ logLine(`[sap-ai-dev] ${name}: SAP_URL has no port; port scan in progress on ${host} (usual ADT ports)`);
217
+ let detected = await detectLocalAdtUrl({ url, client }).catch(() => '');
218
+ if (!detected) {
219
+ logLine(`[sap-ai-dev] ${name}: no ADT port on the usual ports; port scan in progress on every conventional SAP port of ${host} (can take a minute)`);
220
+ detected = await detectLocalAdtUrl({ url, client, exhaustive: true }).catch(() => '');
221
+ }
222
+ logLine(detected
223
+ ? `[sap-ai-dev] ${name}: port scan finished; ADT answers at ${detected}`
224
+ : `[sap-ai-dev] ${name}: port scan finished; no ADT port answered on ${host}; add the port to SAP_URL or rerun sap-ai-dev --setup`);
225
+ url = detected || url;
226
+ }
208
227
  return {
209
228
  source: 'sap-gui-local',
210
229
  name,
211
- url: env.SAP_URL,
230
+ url,
212
231
  client,
213
232
  systemId,
214
233
  authentication: 'SSO',
@@ -280,7 +299,8 @@ async function main() {
280
299
  }
281
300
  if (setup) {
282
301
  const setupOptions = {
283
- includeSapDevelopmentToolsPrompt: hasFlag('--tools') || hasFlag('--companion-tools')
302
+ includeSapDevelopmentToolsPrompt: hasFlag('--tools') || hasFlag('--companion-tools'),
303
+ detectAdtUrl: detectLocalAdtUrl
284
304
  };
285
305
  if (hasFlag('--npx')) {
286
306
  // Windows has no npx.exe, so generated entries there launch through
@@ -335,7 +355,7 @@ async function main() {
335
355
  if (!runtimeEnv.H2O_URL) {
336
356
  if (args().length === 0 && process.stdin.isTTY && process.stdout.isTTY) {
337
357
  console.error('sap-ai-dev: no BAS environment detected; starting local interactive setup. Use --help for other commands.');
338
- await runSetup();
358
+ await runSetup({ detectAdtUrl: detectLocalAdtUrl });
339
359
  return;
340
360
  }
341
361
  const binary = await binaryOrError();
@@ -401,3 +401,31 @@ export function defaultAdtUrl(system) {
401
401
  const authority = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
402
402
  return /^\d{2}$/.test(instance) ? `https://${authority}:443${instance}` : `https://${authority}`;
403
403
  }
404
+
405
+ // new URL() drops a default port, so `https://host:443` must be read from the text.
406
+ export function hasExplicitPort(url) {
407
+ return /^[a-z][a-z0-9+.-]*:\/\/[^/?#]*:\d+(?:[/?#]|$)/i.test(String(url || '').trim());
408
+ }
409
+
410
+ // A portless URL means 443, which on-premise ICMs rarely serve; VSP's `detect`
411
+ // probes the conventional ADT ports and reports which one answers.
412
+ export async function detectAdtUrl(binary, { url, client, env = process.env, exhaustive = false, ports = [], timeoutMs = 180_000 } = {}) {
413
+ let configured;
414
+ try { configured = new URL(url); } catch { return ''; }
415
+ const host = configured.hostname.replace(/^\[(.*)\]$/, '$1');
416
+ const args = ['detect', host, '--json', ...(client ? ['--client', String(client)] : []), ...(exhaustive ? ['--all'] : []), ...ports.flatMap(port => ['--port', String(port)])];
417
+ // A corporate HTTPS_PROXY rarely routes internal SAP hosts; probe them directly.
418
+ const noProxy = [env.NO_PROXY || env.no_proxy, host].filter(Boolean).join(',');
419
+ env = { ...env, NO_PROXY: noProxy, no_proxy: noProxy };
420
+ const [command, ...prefixArgs] = /\.(?:mjs|cjs|js)$/i.test(binary)
421
+ ? [process.execPath, binary]
422
+ : (/\.(?:cmd|bat)$/i.test(binary) && platform() === 'win32' ? ['cmd', '/c', binary] : [binary]);
423
+ try {
424
+ const { stdout } = await execFileAsync(command, [...prefixArgs, ...args], { env, windowsHide: true, timeout: timeoutMs, maxBuffer: 1024 * 1024 });
425
+ const adt = (JSON.parse(stdout)?.findings || []).filter(finding => finding?.kind === 'adt' && /^https?:\/\//i.test(String(finding.url || '')));
426
+ // The SSO session cookie is the whole credential; never trade https for http.
427
+ return (adt.find(finding => finding.secure) || (configured.protocol === 'http:' ? adt[0] : undefined))?.url || '';
428
+ } catch {
429
+ return '';
430
+ }
431
+ }
@@ -106,6 +106,8 @@ export async function resolveMcpServerCommand(env = process.env) {
106
106
  : ['sap-ai-dev'];
107
107
  const searchPath = String(env.PATH || env.Path || '').split(delimiter).filter(Boolean);
108
108
  for (const directory of searchPath) {
109
+ // npx prepends its disposable cache bin dir; entries pointing there go stale or vanish with the cache.
110
+ if (/[\\/]_npx[\\/][^\\/]+[\\/]node_modules[\\/]\.bin[\\/]?$/.test(directory)) continue;
109
111
  for (const executableName of executableNames) {
110
112
  const candidate = resolve(directory, executableName);
111
113
  try {
package/src/setup.mjs CHANGED
@@ -4,7 +4,7 @@ import { platform } from 'node:os';
4
4
  import { isIP } from 'node:net';
5
5
  import { checkboxPrompt, colorText, formatStatus, selectPrompt, startProgress, textPrompt } from './terminal-ui.mjs';
6
6
  import { discoverDestinations, remediation } from './bas-discovery.mjs';
7
- import { defaultAdtUrl, discoverSapGuiSystems } from './local-sap-gui.mjs';
7
+ import { defaultAdtUrl, discoverSapGuiSystems, hasExplicitPort } from './local-sap-gui.mjs';
8
8
  import { discoverCloudFoundryDestinations, getCloudFoundryTarget, deleteManagedCloudFoundryServiceKeys, findOrphanedCloudFoundryServiceKeys } from './cf-destination.mjs';
9
9
  import { SAP_DEVELOPMENT_MCP_SERVERS, collectCloudFoundryKeyReferencesFromAllEntries, collectManagedCloudFoundryKeyReferences, installMcpConfig, readMcpConfig, resolveMcpConfigPath } from './mcp-config.mjs';
10
10
  import { discoverCertificateDnsNames } from './tls-adt-proxy.mjs';
@@ -124,6 +124,43 @@ function localAuthenticationLabel(authMode) {
124
124
  return authMode === 'sso' ? 'SSO' : 'Basic';
125
125
  }
126
126
 
127
+ // The SSO browser signs in on the ADT URL itself, and SAP GUI landscapes never
128
+ // carry the HTTP port, so a portless URL would send it to 443.
129
+ async function detectSsoAdtPort(destination, label, client, { input = stdin, output = stdout, env = process.env, detectAdtUrl } = {}) {
130
+ if (!detectAdtUrl || hasExplicitPort(destination.url)) return;
131
+ const scan = async (message, options) => {
132
+ const stopProgress = startProgress(message, { output, env, label: 'SSO' });
133
+ try { return await detectAdtUrl({ url: destination.url, client, ...options }); }
134
+ catch { return ''; }
135
+ finally { stopProgress(); }
136
+ };
137
+ print(output, formatStatus(`Port scan in progress on ${new URL(destination.url).hostname} to find the ADT port for ${label}.`, 'progress', output, 'SSO'));
138
+ let detected = await scan(`Detecting the ADT port for ${label}`, {});
139
+ if (!detected) {
140
+ print(output, formatStatus(`No ADT port answered on the usual ports for ${label}; port scan in progress on every conventional SAP port (this can take a minute).`, 'progress', output, 'SSO'));
141
+ detected = await scan(`Sweeping SAP HTTP(S) ports for ${label}`, { exhaustive: true });
142
+ }
143
+ // Nothing on the machine knows the port, so the person who does is asked until one answers.
144
+ while (!detected) {
145
+ const host = new URL(destination.url).hostname;
146
+ print(output, formatStatus(`No ADT port answered on ${host}. Ask basis or check SMICM (Goto > Services) for the HTTPS port.`, 'warning', output, 'SSO'));
147
+ const answer = String(await textPrompt({
148
+ message: `ADT port or URL for ${label}`,
149
+ placeholder: '44300 or https://host:44300',
150
+ validate: value => /^\d{2,5}$/.test(String(value).trim()) || validateAdtUrlCandidate(String(value).trim())
151
+ }, { input, output })).trim();
152
+ const candidate = /^\d+$/.test(answer) ? `${new URL(destination.url).origin}:${answer}` : answer;
153
+ const port = Number(new URL(candidate).port || (candidate.startsWith('http:') ? 80 : 443));
154
+ detected = await detectAdtUrl({ url: candidate, client, ports: [port] }).catch(() => '');
155
+ if (!detected && await confirmPrompt(`${candidate} did not answer as ADT. Use it anyway? (y/Enter=yes, n=try another)`, { input, output })) {
156
+ destination.url = candidate;
157
+ return;
158
+ }
159
+ }
160
+ destination.url = detected;
161
+ print(output, formatStatus(`ADT answers at ${detected}; SSO signs in there.`, 'success', output, 'SSO'));
162
+ }
163
+
127
164
  async function confirmPrompt(message, { input = stdin, output = stdout } = {}) {
128
165
  const answer = await textPrompt({
129
166
  message,
@@ -181,7 +218,7 @@ async function maybeConfigureTlsSelfHeal(destination, promptLabel, { input = std
181
218
  // collects the minimal ADT configuration directly from the user and produces
182
219
  // the same local destination shape as the SAP GUI flow, so installation,
183
220
  // credential inputs, and TLS handling stay identical.
184
- async function runManualEntryWizard({ env = process.env, input = stdin, output = stdout } = {}) {
221
+ async function runManualEntryWizard({ env = process.env, input = stdin, output = stdout, detectAdtUrl } = {}) {
185
222
  print(output, '');
186
223
  print(output, colorText('✍️ Manual system entry — skip discovery, add your SAP system directly', 'magenta', output));
187
224
  print(output, colorText(' The ADT URL is the base URL that Eclipse ADT or VS Code ADT tools use, for example https://myhost.example:44300.', 'cyan', output));
@@ -229,6 +266,7 @@ async function runManualEntryWizard({ env = process.env, input = stdin, output =
229
266
  await maybeConfigureTlsSelfHeal(destination, name, { input, output });
230
267
  const clients = await promptSapClients(destination, { input, output });
231
268
  const authMode = await chooseLocalAuthMode(destination, { input, output, env });
269
+ if (authMode === 'sso') await detectSsoAdtPort(destination, name, clients[0], { input, output, env, detectAdtUrl });
232
270
  destination.authentication = localAuthenticationLabel(authMode);
233
271
  destination.authMode = authMode;
234
272
  for (const client of clients) {
@@ -346,7 +384,7 @@ function printConnectionInstructions(output, result) {
346
384
 
347
385
  // Checkbox selection over the discovered destinations plus, outside BAS, the
348
386
  // per-system confirmation prompts (clients, ADT URL, TLS self-heal, login).
349
- async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin, output = stdout, env = process.env } = {}) {
387
+ async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin, output = stdout, env = process.env, detectAdtUrl } = {}) {
350
388
  const orderedDestinations = [...destinations].sort((left, right) => {
351
389
  const leftRank = left.disabledReason ? 2 : (left.probe?.available === false ? 1 : 0);
352
390
  const rightRank = right.disabledReason ? 2 : (right.probe?.available === false ? 1 : 0);
@@ -371,9 +409,8 @@ async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin
371
409
  print(output, '');
372
410
  print(output, colorText(' Space = select/deselect · a = toggle all · m = add a system manually · Enter = confirm.', 'cyan', output));
373
411
  print(output, colorText(' 💡 No system listed, or need an extra one? Press m to skip discovery and add any SAP system manually.', 'magenta', output));
374
- print(output, ' Nothing selected removes this add-on’s MCP entries.');
375
412
  print(output, '');
376
- const addManually = async () => (await runManualEntryWizard({ env, input, output })).map(destination => ({
413
+ const addManually = async () => (await runManualEntryWizard({ env, input, output, detectAdtUrl })).map(destination => ({
377
414
  value: destination,
378
415
  name: `${destination.name} (manual, client ${destination.client})`
379
416
  }));
@@ -381,8 +418,10 @@ async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin
381
418
  message: colorText('🧭 Select destinations', 'cyan', output),
382
419
  choices,
383
420
  required: false,
421
+ searchable: true,
384
422
  shortcuts: { all: 'a' },
385
- actions: { m: addManually }
423
+ actions: { m: addManually },
424
+ confirmEmpty: 'Press Enter again to continue with none (removes this add-on’s MCP entries).'
386
425
  }, { input, output });
387
426
 
388
427
  if (!isBas && selected.length) {
@@ -417,6 +456,7 @@ async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin
417
456
  destination.url = url || fallback;
418
457
  await maybeConfigureTlsSelfHeal(destination, promptLabel, { input, output });
419
458
  const authMode = await chooseLocalAuthMode({ ...destination, name: promptLabel }, { input, output, env });
459
+ if (authMode === 'sso') await detectSsoAdtPort(destination, promptLabel, clients[0], { input, output, env, detectAdtUrl });
420
460
  destination.authentication = localAuthenticationLabel(authMode);
421
461
  destination.authMode = authMode;
422
462
  for (const client of clients) {
@@ -437,7 +477,8 @@ export async function runSetup({
437
477
  discoverLocalSapGui = discoverSapGuiSystems,
438
478
  confirmCfImport = confirmCloudFoundryImport,
439
479
  install = installMcpConfig,
440
- includeSapDevelopmentToolsPrompt = false
480
+ includeSapDevelopmentToolsPrompt = false,
481
+ detectAdtUrl
441
482
  } = {}) {
442
483
  const isBas = Boolean(env.H2O_URL);
443
484
  if (!isBas && (!input.isTTY || !output.isTTY)) {
@@ -584,10 +625,10 @@ export async function runSetup({
584
625
  ]
585
626
  }, { input, output });
586
627
  if (choice !== 'manual') return await exitWithoutDestinations();
587
- selected = await runManualEntryWizard({ env, input, output });
628
+ selected = await runManualEntryWizard({ env, input, output, detectAdtUrl });
588
629
  if (!selected.length) return await exitWithoutDestinations();
589
630
  } else {
590
- selected = await chooseDiscoveredDestinations(destinations, { isBas, input, output, env });
631
+ selected = await chooseDiscoveredDestinations(destinations, { isBas, input, output, env, detectAdtUrl });
591
632
  }
592
633
 
593
634
  let sapDevelopmentServers = [];
@@ -213,11 +213,13 @@ export async function selectPrompt({ message, choices, defaultValue } = {}, { in
213
213
  });
214
214
  }
215
215
 
216
- export async function checkboxPrompt({ message, choices: initialChoices, required = false, shortcuts = { all: 'a' }, actions = {}, validate } = {}, { input = process.stdin, output = process.stdout } = {}) {
216
+ export async function checkboxPrompt({ message, choices: initialChoices, required = false, shortcuts = { all: 'a' }, actions = {}, validate, confirmEmpty = '', searchable = false } = {}, { input = process.stdin, output = process.stdout } = {}) {
217
217
  if (!Array.isArray(initialChoices) || !initialChoices.length) return [];
218
218
  // Action shortcuts (for example 'm' → manual entry wizard) may append new
219
219
  // choices while the picker is open, so the list itself stays mutable.
220
220
  let choices = initialChoices;
221
+ let searchQuery = '';
222
+ let searchMode = false;
221
223
  const selectable = enabledChoices(choices);
222
224
  const selected = new Set(choices.flatMap((choice, index) => choice.checked && !choice.disabled ? [index] : []));
223
225
  let cursor = firstEnabledIndex(choices);
@@ -225,6 +227,31 @@ export async function checkboxPrompt({ message, choices: initialChoices, require
225
227
  let renderedLines = 0;
226
228
  let done = false;
227
229
  let previousRawMode;
230
+ // With confirmEmpty set, Enter on an empty selection shows it and waits for a second Enter.
231
+ let awaitingEmptyConfirm = false;
232
+
233
+ const matchingIndexes = () => choices
234
+ .map((choice, index) => ({ choice, index }))
235
+ .filter(({ choice }) => !searchQuery || String(choice.name || '').toLowerCase().startsWith(searchQuery.toLowerCase()))
236
+ .map(({ index }) => index);
237
+ const visibleIndexes = () => {
238
+ const indexes = matchingIndexes();
239
+ if (searchable && (!indexes.includes(cursor) || choices[cursor]?.disabled)) {
240
+ cursor = indexes.find(index => !choices[index]?.disabled) ?? -1;
241
+ }
242
+ return indexes;
243
+ };
244
+ const moveCursor = step => {
245
+ if (!searchable) {
246
+ cursor = nextEnabledIndex(choices, cursor, step);
247
+ return;
248
+ }
249
+ const indexes = visibleIndexes();
250
+ const visibleChoices = indexes.map(index => choices[index]);
251
+ if (!visibleChoices.some(choice => !choice.disabled)) return;
252
+ const position = nextEnabledIndex(visibleChoices, indexes.indexOf(cursor), step);
253
+ cursor = indexes[position] ?? -1;
254
+ };
228
255
 
229
256
  const write = text => output.write(text);
230
257
  const clearRendered = () => {
@@ -232,32 +259,43 @@ export async function checkboxPrompt({ message, choices: initialChoices, require
232
259
  write(`\u001b[${renderedLines}A\u001b[J`);
233
260
  renderedLines = 0;
234
261
  };
235
- const ensureVisible = pageSize => {
236
- if (cursor < top) top = cursor;
237
- if (cursor >= top + pageSize) top = cursor - pageSize + 1;
238
- top = clamp(top, 0, Math.max(0, choices.length - pageSize));
262
+ const ensureVisible = (pageSize, visibleCount, cursorPosition) => {
263
+ if (cursorPosition >= 0 && cursorPosition < top) top = cursorPosition;
264
+ if (cursorPosition >= top + pageSize) top = cursorPosition - pageSize + 1;
265
+ top = clamp(top, 0, Math.max(0, visibleCount - pageSize));
266
+ };
267
+ const footer = error => {
268
+ if (error) return [[error, 'red']];
269
+ if (awaitingEmptyConfirm) return [['Nothing is selected yet. Press Space to select the highlighted item (❯).', 'yellow'], [confirmEmpty, 'yellow']];
270
+ if (searchable && matchingIndexes().length === 0) return [['No choices match. Backspace or Esc clears search.', 'yellow']];
271
+ if (!selected.size) return [['Press Space to select the highlighted item (❯), then Enter to confirm.', 'cyan']];
272
+ return [[`${selected.size} selected. Press Enter to confirm, or Space to change.`, 'green']];
239
273
  };
240
274
  const render = (error = '') => {
241
275
  const columns = Number.isFinite(output?.columns) ? output.columns : 80;
242
- const pageSize = checkboxPageSize(output, choices.length);
243
- ensureVisible(pageSize);
276
+ const indexes = visibleIndexes();
277
+ const pageSize = searchable
278
+ ? Math.max(1, checkboxPageSize(output, indexes.length + 1) - 1)
279
+ : checkboxPageSize(output, indexes.length);
280
+ const cursorPosition = indexes.indexOf(cursor);
281
+ ensureVisible(pageSize, indexes.length, cursorPosition);
244
282
  const lines = [];
245
283
  lines.push(truncateToColumns(`${message} (Space: select, ${shortcuts?.all || 'a'}: toggle all, Enter: confirm)`, columns));
246
- const end = Math.min(choices.length, top + pageSize);
284
+ if (searchable) lines.push(truncateToColumns(`Search: ${searchQuery || (searchMode ? '' : 'type a prefix; / for a or m')}`, columns));
285
+ const end = Math.min(indexes.length, top + pageSize);
247
286
  for (let index = top; index < end; index += 1) {
248
- const choice = choices[index];
249
- const pointer = index === cursor ? '❯' : ' ';
250
- const marker = selected.has(index)
251
- ? colorText('✓', 'green', output)
252
- : colorText('✗', 'red', output);
287
+ const choiceIndex = indexes[index];
288
+ const choice = choices[choiceIndex];
289
+ const pointer = choiceIndex === cursor ? '❯' : ' ';
290
+ // A red ✗ for "not selected yet" was read as an error, so unselected rows show an empty box.
291
+ const box = choice.disabled ? '[-]' : (selected.has(choiceIndex) ? colorText('[✓]', 'green', output) : '[ ]');
253
292
  const disabled = choice.disabled ? ` — ${choice.disabled}` : '';
254
- const label = `${pointer}${marker} ${choice.name}${disabled}`;
255
- lines.push(truncateToColumns(label, columns));
293
+ lines.push(truncateToColumns(`${pointer} ${box} ${choice.name}${disabled}`, columns));
256
294
  }
257
- if (choices.length > pageSize) {
258
- lines.push(truncateToColumns(colorText(` Showing ${top + 1}-${end} of ${choices.length}; use ↑/↓ to scroll.`, 'cyan', output), columns));
295
+ if (indexes.length > pageSize) {
296
+ lines.push(truncateToColumns(colorText(` Showing ${top + 1}-${end} of ${indexes.length}; use ↑/↓ to scroll.`, 'cyan', output), columns));
259
297
  }
260
- if (error) lines.push(truncateToColumns(colorText(` ${error}`, 'red', output), columns));
298
+ for (const [text, color] of footer(error)) lines.push(truncateToColumns(colorText(` ${text}`, color, output), columns));
261
299
  clearRendered();
262
300
  write(`${lines.join('\n')}\n`);
263
301
  renderedLines = physicalRows(lines, columns);
@@ -322,16 +360,26 @@ export async function checkboxPrompt({ message, choices: initialChoices, require
322
360
  };
323
361
  const onKeypress = (_chunk, key = {}) => {
324
362
  if (key.ctrl && key.name === 'c') return fail(new Error('Prompt interrupted'));
325
- if (key.name === 'up' || key.name === 'k') cursor = nextEnabledIndex(choices, cursor, -1);
326
- else if (key.name === 'down' || key.name === 'j') cursor = nextEnabledIndex(choices, cursor, 1);
327
- else if (key.name === 'space' && !choices[cursor]?.disabled) {
363
+ else if (searchable && key.name === 'escape') {
364
+ searchQuery = '';
365
+ searchMode = false;
366
+ top = 0;
367
+ } else if (searchable && searchMode && (key.name === 'backspace' || key.name === 'delete')) {
368
+ searchQuery = Array.from(searchQuery).slice(0, -1).join('');
369
+ if (!searchQuery) searchMode = false;
370
+ top = 0;
371
+ } else if (searchable && !searchMode && String(_chunk ?? '') === '/' && !key.ctrl && !key.meta) searchMode = true;
372
+ else if (key.name === 'up' || (!searchable && key.name === 'k')) moveCursor(-1);
373
+ else if (key.name === 'down' || (!searchable && key.name === 'j')) moveCursor(1);
374
+ else if (key.name === 'space' && cursor >= 0 && !choices[cursor]?.disabled) {
328
375
  if (selected.has(cursor)) selected.delete(cursor);
329
376
  else selected.add(cursor);
330
- } else if (shortcuts?.all && key.name === shortcuts.all) {
377
+ } else if ((!searchable || !searchMode) && shortcuts?.all && key.name === shortcuts.all) {
331
378
  const allSelected = enabledChoices(choices).every(choice => selected.has(choices.indexOf(choice)));
332
379
  selected.clear();
333
380
  if (!allSelected) for (const choice of enabledChoices(choices)) selected.add(choices.indexOf(choice));
334
- } else if (actionShortcutFor(_chunk, key)) {
381
+ } else if ((!searchable || !searchMode) && actionShortcutFor(_chunk, key)) {
382
+ awaitingEmptyConfirm = false;
335
383
  void runAction(actionShortcutFor(_chunk, key));
336
384
  return;
337
385
  } else if (key.name === 'return' || key.name === 'enter') {
@@ -339,10 +387,19 @@ export async function checkboxPrompt({ message, choices: initialChoices, require
339
387
  const validation = typeof validate === 'function' ? validate(values) : true;
340
388
  if (validation !== true) return render(String(validation));
341
389
  if (required && !values.length) return render('Select at least one item.');
390
+ if (confirmEmpty && !values.length && !awaitingEmptyConfirm) {
391
+ awaitingEmptyConfirm = true;
392
+ return render();
393
+ }
342
394
  return finish(values);
395
+ } else if (searchable && !key.ctrl && !key.meta && Array.from(String(_chunk ?? '')).length === 1 && !/[\u0000-\u001f\u007f]/u.test(String(_chunk))) {
396
+ searchMode = true;
397
+ searchQuery += String(_chunk);
398
+ top = 0;
343
399
  } else {
344
400
  return;
345
401
  }
402
+ awaitingEmptyConfirm = false;
346
403
  render();
347
404
  };
348
405