sap-ai-dev-toolkit 0.9.3 → 0.9.4

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
@@ -325,7 +325,7 @@ Typical local setup flow:
325
325
  sap-ai-dev --setup
326
326
  ```
327
327
 
328
- 1. Select the discovered SAP GUI system with **Space** and confirm with **Enter**.
328
+ 1. Select the discovered SAP GUI system with **Space** and confirm with **Enter**. If no SAP GUI entries are found, setup offers the manual entry wizard: enter a system name, ADT URL, client(s), and login mode instead.
329
329
  2. Confirm the detected/default SAP client, or enter multiple clients such as `100,200`.
330
330
  3. Accept the proposed ADT URL when it is correct, or edit it if your landscape exposes ADT through a different HTTP(S) host/port.
331
331
  4. If setup discovers certificate DNS names for an IP-based HTTPS URL, accept the yes/no TLS self-healing prompt.
@@ -352,7 +352,7 @@ To configure without a global install, run the guided setup directly through npm
352
352
  npx --yes --ignore-scripts sap-ai-dev-toolkit --setup
353
353
  ```
354
354
 
355
- This lists discovered systems in the same checkbox picker, with nothing selected by default. Use **Space** to choose destinations, **Enter** to confirm, and **a** to toggle all (select all if any are unchecked; otherwise clear the selection). Because no global `sap-ai-dev` command is on `PATH`, it writes MCP entries that launch the pinned package version through npx — through `cmd /c npx` on Windows, which is the form VS Code, Claude Code, and Cursor can spawn there. `--ignore-scripts` avoids running the install-time wizard a second time; add `--npx` to force this launcher shape even when the toolkit is installed globally. After setup, in BAS or VS Code run **MCP: List Servers**, select each chosen destination, and choose **Start Server**.
355
+ This lists discovered systems in the same checkbox picker, with nothing 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. Because no global `sap-ai-dev` command is on `PATH`, it writes MCP entries that launch the pinned package version through npx — through `cmd /c npx` on Windows, which is the form VS Code, Claude Code, and Cursor can spawn there. `--ignore-scripts` avoids running the install-time wizard a second time; add `--npx` to force this launcher shape even when the toolkit is installed globally. After setup, in BAS or VS Code run **MCP: List Servers**, select each chosen destination, and choose **Start Server**.
356
356
 
357
357
  For a non-interactive installation or a platform without a published VSP asset, provide a trusted binary override:
358
358
 
@@ -447,7 +447,9 @@ 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, and **a** to toggle all (select all if any are unchecked; otherwise clear the selection). 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 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
+
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.
451
453
 
452
454
  Setup tidies its own footprint: it reconciles MCP entries managed by this package and removes legacy `sapAiDev_*` / `basVspMcp_*` entries from earlier releases. Existing unrelated MCP servers and top-level configuration such as `inputs` stay untouched.
453
455
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sap-ai-dev-toolkit",
3
- "version": "0.9.3",
3
+ "version": "0.9.4",
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",
@@ -17,19 +17,26 @@ function scalar(value) {
17
17
  }
18
18
 
19
19
  function propertiesOf(item) {
20
+ // Nested property maps (BAS wraps destination attributes in `properties`,
21
+ // some proxies use `configuration` or a key/value array) only fill gaps:
22
+ // a nested `Name` — e.g. the Cloud Connector system name `_SIDCLNTOX` —
23
+ // must never overwrite the real destination name at the top level.
20
24
  const result = {};
21
- for (const [key, value] of Object.entries(item || {})) result[keyName(key)] = scalar(value);
22
- const maps = Object.entries(item || {}).filter(([key]) => ['properties', 'propertymap', 'destinationproperties', 'configuration'].includes(keyName(key))).map(([, value]) => value);
25
+ const entries = Object.entries(item || {});
26
+ const maps = entries.filter(([key]) => ['properties', 'propertymap', 'destinationproperties', 'configuration'].includes(keyName(key))).map(([, value]) => value);
23
27
  for (const map of maps) {
24
28
  if (Array.isArray(map)) {
25
29
  for (const entry of map) {
26
30
  const key = entry?.key ?? entry?.name ?? entry?.property;
27
- if (key != null) result[keyName(key)] = scalar(entry?.value ?? entry?.val);
31
+ if (key != null && !(keyName(key) in result)) result[keyName(key)] = scalar(entry?.value ?? entry?.val);
28
32
  }
29
33
  } else if (map && typeof map === 'object') {
30
- for (const [key, value] of Object.entries(map)) result[keyName(key)] = scalar(value);
34
+ for (const [key, value] of Object.entries(map)) {
35
+ if (!(keyName(key) in result)) result[keyName(key)] = scalar(value);
36
+ }
31
37
  }
32
38
  }
39
+ for (const [key, value] of entries) result[keyName(key)] = scalar(value);
33
40
  return result;
34
41
  }
35
42
 
@@ -42,23 +49,36 @@ function listFromBody(body) {
42
49
  if (Array.isArray(body)) return body;
43
50
  if (!body || typeof body !== 'object') return null;
44
51
  for (const [key, value] of Object.entries(body)) {
45
- if (['destinations', 'data'].includes(keyName(key)) && Array.isArray(value)) return value;
52
+ if (!['destinations', 'destinationlist', 'data', 'items', 'value'].includes(keyName(key))) continue;
53
+ if (Array.isArray(value)) return value;
54
+ // Some destination services unwrap records into a name-keyed map
55
+ // instead of an array; heal the shape so discovery still proceeds.
56
+ if (value && typeof value === 'object') {
57
+ const records = Object.entries(value).filter(([, entry]) => entry && typeof entry === 'object').map(([mapName, entry]) => ({ name: mapName, ...entry }));
58
+ if (records.length) return records;
59
+ }
46
60
  }
47
61
  return null;
48
62
  }
49
63
 
50
64
  export function normalizeDestination(item) {
51
65
  const values = propertiesOf(item);
52
- const name = text(values.name || values.destinationname || values.destname);
66
+ const name = text(values.name || values.destinationname || values.destname || values.destination || values.sapdestinationname);
53
67
  const authentication = text(values.authentication || values.authtype || values.auth);
54
- const client = text(values.sapclient || values.client) || '001';
68
+ const client = text(values.sapclient || values.client || values.mandt) || '001';
55
69
  const backendUrl = text(values.url || values.host || values.webideexposedhost);
56
70
  const proxyType = text(values.proxytype || values.proxy);
71
+ // One destination with an unusable name must never abort discovery; the
72
+ // probe reports `invalid-name` for entries whose url stays null.
73
+ let url = null;
74
+ if (name) {
75
+ try { url = destinationUrl(name); } catch { url = null; }
76
+ }
57
77
  return {
58
78
  name,
59
79
  authentication: authentication || 'Unknown',
60
80
  client,
61
- url: name ? destinationUrl(name) : null,
81
+ url,
62
82
  backendUrl: backendUrl || null,
63
83
  proxyType: proxyType || null,
64
84
  rawKeys: Object.keys(item || {}).map(String)
@@ -67,7 +87,10 @@ export function normalizeDestination(item) {
67
87
 
68
88
  export function destinationUrl(name) {
69
89
  const value = text(name);
70
- if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value)) throw new Error(`Invalid BAS destination name: ${value || '<empty>'}`);
90
+ // A leading underscore is valid: BAS destination names allow it (Cloud
91
+ // Connector system names like `_SIDCLNTOX`), and `<name>.dest` resolution
92
+ // inside a dev space is Host-header matching in the BAS proxy, not DNS.
93
+ if (!/^[A-Za-z0-9_][A-Za-z0-9._-]*$/.test(value)) throw new Error(`Invalid BAS destination name: ${value || '<empty>'}`);
71
94
  return `http://${value}.dest`;
72
95
  }
73
96
 
@@ -137,7 +160,7 @@ function proxyDispatcher(target, env) {
137
160
 
138
161
  export async function fetchDestinationList(h2oUrl, options = {}) {
139
162
  if (!h2oUrl) throw new Error('H2O_URL is required for BAS destination discovery');
140
- const url = `${String(h2oUrl).replace(/\/$/, '')}/api/listDestinations`;
163
+ const url = `${String(h2oUrl).trim().replace(/\/+$/, '')}/api/listDestinations`;
141
164
  const env = options.env || process.env;
142
165
  const dispatcher = proxyDispatcher(url, env);
143
166
  try {
@@ -147,7 +170,15 @@ export async function fetchDestinationList(h2oUrl, options = {}) {
147
170
  ...(dispatcher ? { dispatcher } : {})
148
171
  });
149
172
  if (!response.ok) throw new Error(`BAS destination discovery failed with HTTP ${response.status}`);
150
- return await response.json();
173
+ // Parse from text so UTF-8 BOMs and text/plain content types from
174
+ // proxies in front of BAS do not break discovery.
175
+ const payload = await response.text();
176
+ try {
177
+ return JSON.parse(payload.replace(/^\uFEFF/, ''));
178
+ } catch {
179
+ const preview = payload.trim().slice(0, 200);
180
+ throw new Error(`BAS destination discovery returned unparsable JSON${preview ? `: ${preview}` : ''}`);
181
+ }
151
182
  } finally {
152
183
  await dispatcher?.close();
153
184
  }
@@ -186,7 +217,12 @@ function requestProbe(target, proxyUrl, timeoutMs) {
186
217
  }
187
218
 
188
219
  export async function probeADT(destination, options = {}) {
189
- const url = `${destinationUrl(destination.name)}/sap/bc/adt/discovery`;
220
+ let url;
221
+ try {
222
+ url = `${destinationUrl(destination.name)}/sap/bc/adt/discovery`;
223
+ } catch (error) {
224
+ return { status: 'invalid-name', available: false, warning: error.message };
225
+ }
190
226
  if (options.skipProbe) return { status: 'skipped', available: true, url };
191
227
  try {
192
228
  const result = options.probeImpl ? await options.probeImpl(url, options) : await requestProbe(url, options.proxyUrl ?? DEFAULT_PROXY, options.timeoutMs || 5000);
@@ -204,7 +240,13 @@ export async function discoverDestinations(options = {}) {
204
240
  const list = listFromBody(body);
205
241
  if (!list) throw new Error(`Unrecognized BAS destination response shape; top-level keys: ${Object.keys(body || {}).map(String).join(', ') || '<none>'}`);
206
242
  const allow = text(brandedEnvValue(env, 'DESTINATION')).split(',').map(value => value.trim().toLowerCase()).filter(Boolean);
207
- const normalized = list.map(normalizeDestination).filter(item => item.name).filter(item => !allow.length || allow.includes(item.name.toLowerCase()));
243
+ const normalized = list
244
+ .map(item => {
245
+ try { return normalizeDestination(item); }
246
+ catch (error) { return { name: '', probe: { status: 'invalid-name', available: false, warning: error.message } }; }
247
+ })
248
+ .filter(item => item.name)
249
+ .filter(item => !allow.length || allow.includes(item.name.toLowerCase()));
208
250
  const brandedProxy = brandedEnvValue(env, 'HTTP_PROXY');
209
251
  const configuredProxy = brandedProxy !== undefined ? String(brandedProxy) : (env.HTTP_PROXY ?? env.http_proxy);
210
252
  const proxyUrl = configuredProxy !== undefined ? configuredProxy : DEFAULT_PROXY;
package/src/setup.mjs CHANGED
@@ -112,6 +112,167 @@ async function promptSapClients(destination, { input = stdin, output = stdout }
112
112
  return parseSapClientList(answer, fallback);
113
113
  }
114
114
 
115
+ function validateAdtUrlCandidate(candidate) {
116
+ if (!candidate) return 'Enter an ADT base URL, for example https://host:44300.';
117
+ try {
118
+ const parsed = new URL(candidate);
119
+ if (!['http:', 'https:'].includes(parsed.protocol)) return 'Use an http:// or https:// URL.';
120
+ if (parsed.username || parsed.password) return 'Do not embed credentials in the ADT URL; use the login prompts.';
121
+ if (parsed.search || parsed.hash) return 'Enter an ADT base URL without a query or fragment.';
122
+ return true;
123
+ }
124
+ catch { return 'Enter a valid URL.'; }
125
+ }
126
+
127
+ function localAuthenticationLabel(authMode) {
128
+ return ['windows-sso', 'windows-credential-ui', 'windows-sso-basic-fallback'].includes(authMode) ? 'WindowsSSO' : 'Basic';
129
+ }
130
+
131
+ async function confirmPrompt(message, { input = stdin, output = stdout } = {}) {
132
+ const answer = await textPrompt({
133
+ message,
134
+ placeholder: 'y',
135
+ required: false,
136
+ validate: value => /^(?:|y|yes|n|no)$/i.test(String(value || '').trim()) || 'Enter y or n.'
137
+ }, { input, output });
138
+ return !/^n(?:o)?$/i.test(String(answer || '').trim());
139
+ }
140
+
141
+ function buildLocalClientDestination(destination, client, authMode, { multipleClients = false, duplicateSystemName = false } = {}) {
142
+ const name = localClientDestinationName(destination, client, { multipleClients, duplicateSystemName });
143
+ const localDestination = { ...destination, name, serverName: name, client };
144
+ const userInputId = `sap-ai-dev-${localDestination.serverName || localDestination.name}-user`.toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
145
+ const passwordInputId = `sap-ai-dev-${localDestination.serverName || localDestination.name}-password`.toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
146
+ const credentialInputs = [
147
+ { id: userInputId, type: 'promptString', description: `SAP user for ${localDestination.name}` },
148
+ { id: passwordInputId, type: 'promptString', description: `SAP password for ${localDestination.name}`, password: true }
149
+ ];
150
+ if (authMode === 'windows-sso') {
151
+ localDestination.childEnv = { SAP_AUTH_MODE: 'windows-sso' };
152
+ localDestination.inputs = [];
153
+ } else if (authMode === 'windows-credential-ui') {
154
+ localDestination.childEnv = {
155
+ SAP_AUTH_MODE: 'windows-sso',
156
+ SAP_AI_DEV_TOOLKIT_WINDOWS_CREDENTIAL_UI: 'true'
157
+ };
158
+ localDestination.inputs = [];
159
+ } else if (authMode === 'windows-sso-basic-fallback') {
160
+ localDestination.childEnv = {
161
+ SAP_AUTH_MODE: 'windows-sso',
162
+ SAP_AUTH_FALLBACK_MODE: 'basic',
163
+ SAP_USER: `\${input:${userInputId}}`,
164
+ SAP_PASSWORD: `\${input:${passwordInputId}}`
165
+ };
166
+ localDestination.inputs = credentialInputs;
167
+ } else if (authMode === 'browser-saml') {
168
+ localDestination.childEnv = {
169
+ SAP_AUTH_MODE: 'browser-saml',
170
+ SAP_BROWSER_AUTH: 'true',
171
+ SAP_SAML_AUTH: 'true'
172
+ };
173
+ localDestination.inputs = [];
174
+ } else if (authMode === 'saml-password') {
175
+ localDestination.childEnv = {
176
+ SAP_AUTH_MODE: 'saml-password',
177
+ SAP_SAML_AUTH: 'true',
178
+ SAP_SAML_USER: `\${input:${userInputId}}`,
179
+ SAP_SAML_PASSWORD: `\${input:${passwordInputId}}`
180
+ };
181
+ localDestination.inputs = credentialInputs;
182
+ } else {
183
+ localDestination.childEnv = {
184
+ SAP_AUTH_MODE: 'basic',
185
+ SAP_USER: `\${input:${userInputId}}`,
186
+ SAP_PASSWORD: `\${input:${passwordInputId}}`
187
+ };
188
+ localDestination.inputs = credentialInputs;
189
+ }
190
+ return localDestination;
191
+ }
192
+
193
+ async function maybeConfigureTlsSelfHeal(destination, promptLabel, { input = stdin, output = stdout } = {}) {
194
+ const parsedAdtUrl = new URL(destination.url);
195
+ if (parsedAdtUrl.protocol !== 'https:' || !isIP(parsedAdtUrl.hostname)) return;
196
+ print(output, formatStatus('The ADT URL uses an IP address. Setup will inspect the SAP HTTPS certificate and can enable TLS self-healing automatically; certificate validation remains enabled.', 'warning', output, 'TLS self-heal'));
197
+ let discoveredNames = [];
198
+ try { discoveredNames = await discoverCertificateDnsNames(destination.url); }
199
+ catch { discoveredNames = []; }
200
+ if (!discoveredNames.length) {
201
+ print(output, formatStatus('No DNS subjectAltName entries could be read from the SAP HTTPS certificate. TLS self-healing was not configured automatically.', 'warning', output, 'TLS self-heal'));
202
+ return;
203
+ }
204
+ const preview = discoveredNames.slice(0, 5).join(', ');
205
+ const useDiscovered = await confirmPrompt(`Use discovered certificate DNS name${discoveredNames.length === 1 ? '' : 's'} for ${promptLabel}: ${preview}${discoveredNames.length > 5 ? ', ...' : ''}? (y/Enter=yes, n=no)`, { input, output });
206
+ if (useDiscovered) {
207
+ destination.tlsServerNames = discoveredNames;
208
+ destination.tlsServerName = discoveredNames[0];
209
+ }
210
+ }
211
+
212
+ // Manual MCP entry wizard: offered whenever discovery finds nothing to select
213
+ // (no BAS destinations, no Cloud Foundry imports, no SAP GUI entries). It
214
+ // collects the minimal ADT configuration directly from the user and produces
215
+ // the same local destination shape as the SAP GUI flow, so installation,
216
+ // credential inputs, and TLS handling stay identical.
217
+ async function runManualEntryWizard({ env = process.env, input = stdin, output = stdout } = {}) {
218
+ print(output, '');
219
+ print(output, colorText('✍️ Manual system entry — skip discovery, add your SAP system directly', 'magenta', output));
220
+ 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));
221
+ print(output, colorText(' Setup writes one isolated MCP server per system/client pair, with login prompts and TLS self-healing like every other entry.', 'cyan', output));
222
+ const expanded = [];
223
+ const usedNames = new Set();
224
+ let addAnother = true;
225
+ while (addAnother) {
226
+ const name = (await textPrompt({
227
+ message: 'System name',
228
+ placeholder: 'S4H sandbox',
229
+ required: true,
230
+ validate: value => {
231
+ const trimmed = String(value || '').trim();
232
+ if (!trimmed) return 'Enter a name for the system.';
233
+ if (usedNames.has(trimmed.toLowerCase())) return 'A system with this name was already added; choose another name.';
234
+ return true;
235
+ }
236
+ }, { input, output })).trim();
237
+ usedNames.add(name.toLowerCase());
238
+ const url = await textPrompt({
239
+ message: `ADT URL for ${name}`,
240
+ placeholder: 'https://host:44300',
241
+ required: true,
242
+ validate: validateAdtUrlCandidate
243
+ }, { input, output });
244
+ let parsedUrl;
245
+ try { parsedUrl = new URL(url); }
246
+ catch { parsedUrl = null; }
247
+ const destination = {
248
+ source: 'sap-gui-local',
249
+ name,
250
+ serverName: name,
251
+ url,
252
+ host: parsedUrl?.hostname || '',
253
+ client: '001',
254
+ authentication: 'Basic',
255
+ authMode: 'basic',
256
+ proxyType: 'Internet',
257
+ // Marks wizard-configured systems so the discovery confirmation prompts
258
+ // (clients, ADT URL, login) skip them when they flow through the
259
+ // destination picker.
260
+ configuredManually: true
261
+ };
262
+ await maybeConfigureTlsSelfHeal(destination, name, { input, output });
263
+ const clients = await promptSapClients(destination, { input, output });
264
+ const authMode = await chooseLocalAuthMode(destination, { input, output, env });
265
+ destination.authentication = localAuthenticationLabel(authMode);
266
+ destination.authMode = authMode;
267
+ for (const client of clients) {
268
+ expanded.push(buildLocalClientDestination(destination, client, authMode, { multipleClients: clients.length > 1 }));
269
+ }
270
+ print(output, formatStatus(`Added ${name} (client${clients.length === 1 ? '' : 's'} ${clients.join(', ')}).`, 'success', output, 'Manual entry'));
271
+ addAnother = await confirmPrompt('Add another system? (y/Enter=yes, n=no)', { input, output });
272
+ }
273
+ return expanded;
274
+ }
275
+
115
276
  function localSystemDiscriminator(destination) {
116
277
  return [destination.systemId, destination.host, destination.instance].map(value => String(value || '').trim()).filter(Boolean).join(' ');
117
278
  }
@@ -216,6 +377,90 @@ function printConnectionInstructions(output, result) {
216
377
  }
217
378
 
218
379
 
380
+ // Checkbox selection over the discovered destinations plus, outside BAS, the
381
+ // per-system confirmation prompts (clients, ADT URL, TLS self-heal, login).
382
+ async function chooseDiscoveredDestinations(destinations, { isBas, input = stdin, output = stdout, env = process.env } = {}) {
383
+ const orderedDestinations = [...destinations].sort((left, right) => {
384
+ const leftRank = left.disabledReason ? 2 : (left.probe?.available === false ? 1 : 0);
385
+ const rightRank = right.disabledReason ? 2 : (right.probe?.available === false ? 1 : 0);
386
+ return leftRank - rightRank || String(left.name).localeCompare(String(right.name));
387
+ });
388
+ const choices = orderedDestinations.map(destination => {
389
+ const probe = destination.probe?.status || 'unknown';
390
+ const source = destination.source === 'cloud-foundry'
391
+ ? `CF ${destination.cf.destinationInstanceName}`
392
+ : (destination.source === 'sap-gui-local' ? `SAP GUI ${destination.systemId || destination.host || ''}`.trim() : 'BAS');
393
+ const disabled = destination.disabledReason;
394
+ const state = disabled ? 'disabled' : (probe === 'needs-adt-url' ? probe : (destination.probe?.available === false ? `fail:${probe}` : `ok:${probe}`));
395
+ return {
396
+ value: destination,
397
+ name: `${destination.name} (${source}, client ${destination.client}, ${state})`,
398
+ ...(disabled ? { disabled } : {}),
399
+ checked: false
400
+ };
401
+ });
402
+ print(output, '');
403
+ print(output, formatStatus('Choose the destinations to add. Reachable destinations are shown first.', 'step', output, 'Setup'));
404
+ print(output, '');
405
+ print(output, colorText(' Space = select/deselect · a = toggle all · m = add a system manually · Enter = confirm.', 'cyan', output));
406
+ print(output, colorText(' 💡 No system listed, or need an extra one? Press m to skip discovery and add any SAP system manually.', 'magenta', output));
407
+ print(output, ' Nothing selected removes this add-on’s MCP entries.');
408
+ print(output, '');
409
+ const addManually = async () => (await runManualEntryWizard({ env, input, output })).map(destination => ({
410
+ value: destination,
411
+ name: `${destination.name} (manual, client ${destination.client})`
412
+ }));
413
+ let selected = await checkboxPrompt({
414
+ message: colorText('🧭 Select destinations', 'cyan', output),
415
+ choices,
416
+ required: false,
417
+ shortcuts: { all: 'a' },
418
+ actions: { m: addManually }
419
+ }, { input, output });
420
+
421
+ if (!isBas && selected.length) {
422
+ print(output, '');
423
+ print(output, formatStatus('SAP GUI landscapes do not contain ADT HTTP(S) endpoints or a complete client catalog. Confirm the SAP client(s), ADT URL, and authentication for each selected system.', 'step', output, 'Local SAP GUI'));
424
+ print(output, ' Enter multiple clients as comma-separated 3-digit values, for example 100,200. Setup creates one isolated MCP server per system/client pair.');
425
+ if (windowsSsoSetupAvailable(env)) print(output, ' Windows SSO is experimental and only applies to ADT systems configured for HTTP Integrated Authentication (Negotiate/SPNEGO). It uses the current Windows logon session, including smart-card-backed Windows logon; the toolkit cannot prompt for or accept manual bearer/SAML tokens. Username/password remains the default.');
426
+ const selectedNameCounts = selected.reduce((counts, destination) => {
427
+ const key = String(destination.name).toLowerCase();
428
+ counts.set(key, (counts.get(key) || 0) + 1);
429
+ return counts;
430
+ }, new Map());
431
+ const expanded = [];
432
+ for (const destination of selected) {
433
+ // Wizard-configured systems are already complete; only discovered SAP
434
+ // GUI entries need their clients, ADT URL, and login confirmed here.
435
+ if (destination.configuredManually) {
436
+ expanded.push(destination);
437
+ continue;
438
+ }
439
+ const duplicateSystemName = (selectedNameCounts.get(String(destination.name).toLowerCase()) || 0) > 1;
440
+ const discriminator = duplicateSystemName ? localSystemDiscriminator(destination) : '';
441
+ const promptLabel = discriminator ? `${destination.name} (${discriminator})` : destination.name;
442
+ const clients = await promptSapClients({ ...destination, name: promptLabel }, { input, output });
443
+ const fallback = defaultAdtUrl(destination);
444
+ const url = await textPrompt({
445
+ message: `ADT URL for ${promptLabel}`,
446
+ placeholder: fallback || 'https://host:44300',
447
+ validate: value => validateAdtUrlCandidate(value || fallback),
448
+ required: false
449
+ }, { input, output });
450
+ destination.url = url || fallback;
451
+ await maybeConfigureTlsSelfHeal(destination, promptLabel, { input, output });
452
+ const authMode = await chooseLocalAuthMode({ ...destination, name: promptLabel }, { input, output, env });
453
+ destination.authentication = localAuthenticationLabel(authMode);
454
+ destination.authMode = authMode;
455
+ for (const client of clients) {
456
+ expanded.push(buildLocalClientDestination(destination, client, authMode, { multipleClients: clients.length > 1, duplicateSystemName }));
457
+ }
458
+ }
459
+ selected = expanded;
460
+ }
461
+ return selected;
462
+ }
463
+
219
464
  export async function runSetup({
220
465
  env = process.env,
221
466
  input = stdin,
@@ -237,6 +482,7 @@ export async function runSetup({
237
482
  return { skipped: true, reason: 'non-tty' };
238
483
  }
239
484
  print(output, '');
485
+ print(output, colorText('💡 Tip: you can skip auto-discovery and add a system manually — press m in the destination picker, or take the wizard offer when nothing is found.', 'magenta', output));
240
486
  if (!isBas) print(output, formatStatus('Looking for local SAP GUI system configuration.', 'progress', output, 'Setup'));
241
487
 
242
488
  let destinations = [];
@@ -349,160 +595,32 @@ export async function runSetup({
349
595
  return result.warnings || [];
350
596
  };
351
597
  const selectable = destinations.filter(destination => !destination.disabledReason);
352
- if (!selectable.length) {
353
- print(output, formatStatus(isBas ? 'No selectable BAS or Cloud Foundry destinations were found.' : 'No SAP GUI systems were found on this computer.', 'warning', output, 'Setup'));
354
- print(output, isBas ? remediation : 'Install SAP GUI and create SAP Logon entries, or set SAP_AI_DEV_MCP_CONFIG and add systems manually. ADT still needs an HTTP(S) URL; SAP GUI files contain DIAG routing only.');
598
+ const exitWithoutDestinations = async () => {
355
599
  await reportWarnings(warnings);
356
600
  const cleanupWarnings = await cleanupNewKeysWithoutConfigChange();
357
601
  await reportWarnings(cleanupWarnings);
358
602
  return { skipped: true, reason: 'no-destinations', warnings: [...warnings, ...cleanupWarnings] };
359
- }
360
-
361
- const orderedDestinations = [...destinations].sort((left, right) => {
362
- const leftRank = left.disabledReason ? 2 : (left.probe?.available === false ? 1 : 0);
363
- const rightRank = right.disabledReason ? 2 : (right.probe?.available === false ? 1 : 0);
364
- return leftRank - rightRank || String(left.name).localeCompare(String(right.name));
365
- });
366
- const choices = orderedDestinations.map(destination => {
367
- const probe = destination.probe?.status || 'unknown';
368
- const source = destination.source === 'cloud-foundry'
369
- ? `CF ${destination.cf.destinationInstanceName}`
370
- : (destination.source === 'sap-gui-local' ? `SAP GUI ${destination.systemId || destination.host || ''}`.trim() : 'BAS');
371
- const disabled = destination.disabledReason;
372
- const state = disabled ? 'disabled' : (probe === 'needs-adt-url' ? probe : (destination.probe?.available === false ? `fail:${probe}` : `ok:${probe}`));
373
- return {
374
- value: destination,
375
- name: `${destination.name} (${source}, client ${destination.client}, ${state})`,
376
- ...(disabled ? { disabled } : {}),
377
- checked: false
378
- };
379
- });
380
- print(output, '');
381
- print(output, formatStatus('Choose the destinations to add. Reachable destinations are shown first.', 'step', output, 'Setup'));
382
- print(output, '');
383
- print(output, ' Space = select/deselect · a = toggle all · Enter = confirm.');
384
- print(output, ' Nothing selected removes this add-on’s MCP entries.');
385
- print(output, '');
386
- let selected = await checkboxPrompt({
387
- message: colorText('🧭 Select destinations', 'cyan', output),
388
- choices,
389
- required: false,
390
- shortcuts: { all: 'a' }
391
- }, { input, output });
392
-
393
- if (!isBas && selected.length) {
603
+ };
604
+ let selected;
605
+ if (!selectable.length) {
606
+ // Nothing was detected — no BAS/CF destinations, or no SAP GUI entries.
607
+ // Offer the manual MCP entry wizard before giving up.
608
+ print(output, formatStatus(isBas ? 'No selectable BAS or Cloud Foundry destinations were found.' : 'No SAP GUI systems were found on this computer.', 'warning', output, 'Setup'));
609
+ print(output, isBas ? remediation : 'Install SAP GUI and create SAP Logon entries, or configure your system manually below. ADT still needs an HTTP(S) URL; SAP GUI files contain DIAG routing only.');
394
610
  print(output, '');
395
- print(output, formatStatus('SAP GUI landscapes do not contain ADT HTTP(S) endpoints or a complete client catalog. Confirm the SAP client(s), ADT URL, and authentication for each selected system.', 'step', output, 'Local SAP GUI'));
396
- print(output, ' Enter multiple clients as comma-separated 3-digit values, for example 100,200. Setup creates one isolated MCP server per system/client pair.');
397
- if (windowsSsoSetupAvailable(env)) print(output, ' Windows SSO is experimental and only applies to ADT systems configured for HTTP Integrated Authentication (Negotiate/SPNEGO). It uses the current Windows logon session, including smart-card-backed Windows logon; the toolkit cannot prompt for or accept manual bearer/SAML tokens. Username/password remains the default.');
398
- const selectedNameCounts = selected.reduce((counts, destination) => {
399
- const key = String(destination.name).toLowerCase();
400
- counts.set(key, (counts.get(key) || 0) + 1);
401
- return counts;
402
- }, new Map());
403
- const expanded = [];
404
- for (const destination of selected) {
405
- const duplicateSystemName = (selectedNameCounts.get(String(destination.name).toLowerCase()) || 0) > 1;
406
- const discriminator = duplicateSystemName ? localSystemDiscriminator(destination) : '';
407
- const promptLabel = discriminator ? `${destination.name} (${discriminator})` : destination.name;
408
- const clients = await promptSapClients({ ...destination, name: promptLabel }, { input, output });
409
- const fallback = defaultAdtUrl(destination);
410
- const url = await textPrompt({
411
- message: `ADT URL for ${promptLabel}`,
412
- placeholder: fallback || 'https://host:44300',
413
- validate: value => {
414
- const candidate = value || fallback;
415
- if (!candidate) return 'Enter an ADT base URL, for example https://host:44300.';
416
- try {
417
- const parsed = new URL(candidate);
418
- if (!['http:', 'https:'].includes(parsed.protocol)) return 'Use an http:// or https:// URL.';
419
- if (parsed.username || parsed.password) return 'Do not embed credentials in the ADT URL; use the login prompts.';
420
- if (parsed.search || parsed.hash) return 'Enter an ADT base URL without a query or fragment.';
421
- return true;
422
- }
423
- catch { return 'Enter a valid URL.'; }
424
- },
425
- required: false
426
- }, { input, output });
427
- destination.url = url || fallback;
428
- const parsedAdtUrl = new URL(destination.url);
429
- if (parsedAdtUrl.protocol === 'https:' && isIP(parsedAdtUrl.hostname)) {
430
- print(output, formatStatus('The ADT URL uses an IP address. Setup will inspect the SAP HTTPS certificate and can enable TLS self-healing automatically; certificate validation remains enabled.', 'warning', output, 'TLS self-heal'));
431
- let discoveredNames = [];
432
- try { discoveredNames = await discoverCertificateDnsNames(destination.url); }
433
- catch { discoveredNames = []; }
434
- if (discoveredNames.length) {
435
- const preview = discoveredNames.slice(0, 5).join(', ');
436
- const answer = await textPrompt({
437
- message: `Use discovered certificate DNS name${discoveredNames.length === 1 ? '' : 's'} for ${promptLabel}: ${preview}${discoveredNames.length > 5 ? ', ...' : ''}? (y/Enter=yes, n=no)`,
438
- placeholder: 'y',
439
- required: false,
440
- validate: value => /^(?:|y|yes|n|no)$/i.test(String(value || '').trim()) || 'Enter y or n.'
441
- }, { input, output });
442
- if (!/^n(?:o)?$/i.test(String(answer || '').trim())) {
443
- destination.tlsServerNames = discoveredNames;
444
- destination.tlsServerName = discoveredNames[0];
445
- }
446
- } else {
447
- print(output, formatStatus('No DNS subjectAltName entries could be read from the SAP HTTPS certificate. TLS self-healing was not configured automatically.', 'warning', output, 'TLS self-heal'));
448
- }
449
- }
450
- const authMode = await chooseLocalAuthMode({ ...destination, name: promptLabel }, { input, output, env });
451
- destination.authentication = ['windows-sso', 'windows-credential-ui', 'windows-sso-basic-fallback'].includes(authMode) ? 'WindowsSSO' : 'Basic';
452
- destination.authMode = authMode;
453
- for (const client of clients) {
454
- const name = localClientDestinationName(destination, client, { multipleClients: clients.length > 1, duplicateSystemName });
455
- const localDestination = { ...destination, name, serverName: name, client };
456
- const userInputId = `sap-ai-dev-${localDestination.serverName || localDestination.name}-user`.toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
457
- const passwordInputId = `sap-ai-dev-${localDestination.serverName || localDestination.name}-password`.toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
458
- const credentialInputs = [
459
- { id: userInputId, type: 'promptString', description: `SAP user for ${localDestination.name}` },
460
- { id: passwordInputId, type: 'promptString', description: `SAP password for ${localDestination.name}`, password: true }
461
- ];
462
- if (authMode === 'windows-sso') {
463
- localDestination.childEnv = { SAP_AUTH_MODE: 'windows-sso' };
464
- localDestination.inputs = [];
465
- } else if (authMode === 'windows-credential-ui') {
466
- localDestination.childEnv = {
467
- SAP_AUTH_MODE: 'windows-sso',
468
- SAP_AI_DEV_TOOLKIT_WINDOWS_CREDENTIAL_UI: 'true'
469
- };
470
- localDestination.inputs = [];
471
- } else if (authMode === 'windows-sso-basic-fallback') {
472
- localDestination.childEnv = {
473
- SAP_AUTH_MODE: 'windows-sso',
474
- SAP_AUTH_FALLBACK_MODE: 'basic',
475
- SAP_USER: `\${input:${userInputId}}`,
476
- SAP_PASSWORD: `\${input:${passwordInputId}}`
477
- };
478
- localDestination.inputs = credentialInputs;
479
- } else if (authMode === 'browser-saml') {
480
- localDestination.childEnv = {
481
- SAP_AUTH_MODE: 'browser-saml',
482
- SAP_BROWSER_AUTH: 'true',
483
- SAP_SAML_AUTH: 'true'
484
- };
485
- localDestination.inputs = [];
486
- } else if (authMode === 'saml-password') {
487
- localDestination.childEnv = {
488
- SAP_AUTH_MODE: 'saml-password',
489
- SAP_SAML_AUTH: 'true',
490
- SAP_SAML_USER: `\${input:${userInputId}}`,
491
- SAP_SAML_PASSWORD: `\${input:${passwordInputId}}`
492
- };
493
- localDestination.inputs = credentialInputs;
494
- } else {
495
- localDestination.childEnv = {
496
- SAP_AUTH_MODE: 'basic',
497
- SAP_USER: `\${input:${userInputId}}`,
498
- SAP_PASSWORD: `\${input:${passwordInputId}}`
499
- };
500
- localDestination.inputs = credentialInputs;
501
- }
502
- expanded.push(localDestination);
503
- }
504
- }
505
- selected = expanded;
611
+ const choice = await selectPrompt({
612
+ message: colorText('💡 No systems were detected. Configure an SAP system manually?', 'magenta', output),
613
+ defaultValue: 'manual',
614
+ choices: [
615
+ { name: '✍️ Start manual entry wizard (system name, ADT URL, client, login)', value: 'manual', shortcut: 'm' },
616
+ { name: 'Exit setup without changes', value: 'exit', shortcut: 'e' }
617
+ ]
618
+ }, { input, output });
619
+ if (choice !== 'manual') return await exitWithoutDestinations();
620
+ selected = await runManualEntryWizard({ env, input, output });
621
+ if (!selected.length) return await exitWithoutDestinations();
622
+ } else {
623
+ selected = await chooseDiscoveredDestinations(destinations, { isBas, input, output, env });
506
624
  }
507
625
 
508
626
  let sapDevelopmentServers = [];
@@ -213,8 +213,11 @@ export async function selectPrompt({ message, choices, defaultValue } = {}, { in
213
213
  });
214
214
  }
215
215
 
216
- export async function checkboxPrompt({ message, choices, required = false, shortcuts = { all: 'a' }, validate } = {}, { input = process.stdin, output = process.stdout } = {}) {
217
- if (!Array.isArray(choices) || !choices.length) return [];
216
+ export async function checkboxPrompt({ message, choices: initialChoices, required = false, shortcuts = { all: 'a' }, actions = {}, validate } = {}, { input = process.stdin, output = process.stdout } = {}) {
217
+ if (!Array.isArray(initialChoices) || !initialChoices.length) return [];
218
+ // Action shortcuts (for example 'm' → manual entry wizard) may append new
219
+ // choices while the picker is open, so the list itself stays mutable.
220
+ let choices = initialChoices;
218
221
  const selectable = enabledChoices(choices);
219
222
  const selected = new Set(choices.flatMap((choice, index) => choice.checked && !choice.disabled ? [index] : []));
220
223
  let cursor = firstEnabledIndex(choices);
@@ -261,6 +264,42 @@ export async function checkboxPrompt({ message, choices, required = false, short
261
264
  };
262
265
 
263
266
  return new Promise((resolve, reject) => {
267
+ const actionShortcuts = new Map(Object.entries(actions).filter(([shortcut, action]) => shortcut && typeof action === 'function'));
268
+ const actionShortcutFor = (chunk, key = {}) => {
269
+ if (!actionShortcuts.size) return undefined;
270
+ const typed = String(chunk ?? '').toLowerCase();
271
+ if (typed.length === 1 && actionShortcuts.has(typed)) return actionShortcuts.get(typed);
272
+ if (key.name && actionShortcuts.has(key.name)) return actionShortcuts.get(key.name);
273
+ return undefined;
274
+ };
275
+ // Running an action suspends the picker (listener detached, raw mode off),
276
+ // lets the action drive its own prompts on the same streams, and appends
277
+ // the produced items as pre-selected choices before redrawing. An aborted
278
+ // action (Ctrl+C in a sub-prompt) returns to the picker unchanged instead
279
+ // of tearing down the whole selection.
280
+ const runAction = async action => {
281
+ input.off('keypress', onKeypress);
282
+ if (typeof input.setRawMode === 'function') input.setRawMode(false);
283
+ clearRendered();
284
+ let appended = [];
285
+ try { appended = await action() || []; }
286
+ catch { appended = []; }
287
+ const added = (Array.isArray(appended) ? appended : []).filter(item => item && item.value !== undefined);
288
+ if (added.length) {
289
+ for (const item of added) {
290
+ choices.push({ checked: true, ...item });
291
+ selected.add(choices.length - 1);
292
+ }
293
+ cursor = choices.length - added.length;
294
+ }
295
+ if (typeof input.setRawMode === 'function') input.setRawMode(previousRawMode);
296
+ // Sub-prompts pause the stream on cleanup; resume so keypresses reach
297
+ // the re-attached picker listener.
298
+ if (typeof input.resume === 'function') input.resume();
299
+ input.on('keypress', onKeypress);
300
+ render();
301
+ };
302
+
264
303
  const cleanup = () => {
265
304
  input.off('keypress', onKeypress);
266
305
  if (typeof input.setRawMode === 'function' && previousRawMode !== undefined) input.setRawMode(previousRawMode);
@@ -289,9 +328,12 @@ export async function checkboxPrompt({ message, choices, required = false, short
289
328
  if (selected.has(cursor)) selected.delete(cursor);
290
329
  else selected.add(cursor);
291
330
  } else if (shortcuts?.all && key.name === shortcuts.all) {
292
- const allSelected = selectable.every(choice => selected.has(choices.indexOf(choice)));
331
+ const allSelected = enabledChoices(choices).every(choice => selected.has(choices.indexOf(choice)));
293
332
  selected.clear();
294
- if (!allSelected) for (const choice of selectable) selected.add(choices.indexOf(choice));
333
+ if (!allSelected) for (const choice of enabledChoices(choices)) selected.add(choices.indexOf(choice));
334
+ } else if (actionShortcutFor(_chunk, key)) {
335
+ void runAction(actionShortcutFor(_chunk, key));
336
+ return;
295
337
  } else if (key.name === 'return' || key.name === 'enter') {
296
338
  const values = [...selected].sort((a, b) => a - b).map(index => choices[index].value);
297
339
  const validation = typeof validate === 'function' ? validate(values) : true;