sap-ai-dev-toolkit 0.7.8 → 0.8.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.
package/README.md CHANGED
@@ -52,13 +52,13 @@ To also add optional full-stack SAP companion MCP servers for Fiori, UI5, CAP, a
52
52
  sap-ai-dev --setup --tools
53
53
  ```
54
54
 
55
- Then connect a destination in SAP Business Application Studio:
55
+ Then connect a destination in SAP Business Application Studio, or run the same setup locally to discover SAP GUI systems and create VS Code MCP entries:
56
56
 
57
57
  1. Open the Command Palette.
58
58
  2. Run **MCP: List Servers**.
59
- 3. Start the server named after your selected BAS destination (the lowercase slug, for example `demo-abap` for destination `DEMO_ABAP`).
59
+ 3. Start the server named after your selected destination (the lowercase slug, for example `demo-abap` for destination `DEMO_ABAP`).
60
60
  4. In your AI assistant's chat, choose the best-fit bundled agent from the agent picker (GitHub Copilot, Claude Code, and opencode install the agents) or invoke the matching Pi prompt template (`/sap-solution-architect`, `/abap-developer`, `/abap-runtime-debugger`, `/rap-service-developer`, or `/hana-cloud-hdi-specialist`): **SAP Solution Architect**, **ABAP Developer**, **ABAP Runtime Debugger**, **RAP Service Developer**, or **HANA Cloud/HDI Specialist**.
61
- 5. In the Chat tools picker, enable the server for that BAS destination.
61
+ 5. In the Chat tools picker, enable the server for that destination.
62
62
  6. Ask the agent to inspect, build, test, or verify something in your SAP landscape.
63
63
 
64
64
  Use the attached destination-prefixed tools directly in chat. Do not launch `sap-ai-dev` or handcraft MCP JSON-RPC in a terminal to discover or call them.
@@ -236,13 +236,13 @@ Once the destination server is enabled in your chat's tools picker, ask for outc
236
236
  | 🔒 **SAP authorization remains authoritative** | The add-on does not bypass backend SAP permissions. |
237
237
  | 🚚 **Curated VSP tool surface** | The proxy exposes the same curated developer-lifecycle set for every destination, plus local workflow tools; SAP authorizations and VSP safety checks still apply. |
238
238
  | 🧱 **Per-destination isolation** | Generated MCP entries are scoped to a single `SAP_AI_DEV_TOOLKIT_DESTINATION`. |
239
- | 🔑 **No credentials in `mcp.json`** | Authentication material stays in BAS destination configuration rather than MCP config. |
239
+ | 🔑 **Credential-aware setup** | BAS authentication material stays in Destination configuration. Local SAP GUI setup writes VS Code input prompts for SAP user/password instead of raw credentials. |
240
240
 
241
241
  <a id="install"></a>
242
242
 
243
243
  ## 📦 Installation
244
244
 
245
- Install globally from a BAS dev space:
245
+ Install globally from SAP Business Application Studio or from a local workstation with SAP GUI installed:
246
246
 
247
247
  ```sh
248
248
  npm install --global sap-ai-dev-toolkit
@@ -270,7 +270,9 @@ The installer handles VSP provisioning automatically:
270
270
  - Uses the package's bundled patched VSP binary for the current platform, verified against the npm-published `dist/checksums.txt`. A checksum-anchored remote download is the fallback only when the package has no bundled asset.
271
271
  - No Go toolchain is downloaded or required at install time; Go is only used by the repository's own `build:vsp` development script.
272
272
 
273
- With `H2O_URL` set, an interactive install opens a checkbox picker 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. npm may run its install hook without an interactive terminal, even when the shell is interactive; in that case, selection is skipped without changing MCP config.
273
+ 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.
274
+
275
+ 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, so setup asks you to confirm the ADT URL for each selected system. 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 can instead write an experimental `SAP_AUTH_MODE=windows-sso` entry without username/password prompts; this requires the ADT endpoint to support HTTP Negotiate/SPNEGO. Runtime uses the bundled Windows Negotiate helper by default, or a trusted custom helper configured with `SAP_AI_DEV_TOOLKIT_NEGOTIATE_HELPER`. 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.
274
276
 
275
277
 
276
278
  The same postinstall offers the bundled agents and skills for several AI coding harnesses in a checkbox picker. **GitHub Copilot** and **Claude Code** start pre-checked, so pressing **Enter** alone installs both; **Space** selects or deselects a harness, **a** toggles all seven, and confirming with none selected skips the step without changing files. When destination MCP setup succeeds, the generated managed MCP servers are also mirrored into selected harnesses that have a stable user-level JSON MCP configuration (Claude Code, Cursor, Gemini CLI, and Pi Coding Agent). GitHub Copilot continues to use the BAS/VS Code MCP configuration written during destination setup. With no interactive terminal, the assets are installed for GitHub Copilot only; set `SAP_AI_DEV_TOOLKIT_HARNESSES` to a comma-separated list of harness ids (`github-copilot`, `claude-code`, `codex`, `cursor`, `gemini-cli`, `opencode`, `pi-coding-agent`) to choose non-interactively.
@@ -279,9 +281,9 @@ Some current npm versions also require install hooks to be approved. If npm repo
279
281
 
280
282
  The setup report uses icons and terminal colors; set `NO_COLOR=1` to disable ANSI colors. The table is a weather report, not a bouncer: green **PASS** means the ADT probe responded, red **FAIL** means it failed, and yellow **SKIPPED** means it was skipped. Probe failures do not block MCP registration or startup for destinations you select.
281
283
 
282
- Run `sap-ai-dev --setup` later to change the destination selection or remove generated destination entries. Run `sap-ai-dev --setup --tools` to also choose optional companion tools. Use `--npx` to make generated BAS/VSP destination entries start the pinned package through npm instead of relying on a global `sap-ai-dev` command; companion tools already use `npx` with their own npm packages.
284
+ Run `sap-ai-dev --setup` later to change the destination selection or remove generated destination entries. Run `sap-ai-dev --setup --tools` to also choose optional companion tools. Use `--npx` to make generated destination entries start the pinned package through npm instead of relying on a global `sap-ai-dev` command; companion tools already use `npx` with their own npm packages.
283
285
 
284
- If a setup step is skipped or fails, rerun it from an interactive BAS terminal:
286
+ If a setup step is skipped or fails, rerun it from an interactive terminal:
285
287
 
286
288
  ```sh
287
289
  sap-ai-dev --setup
@@ -293,7 +295,7 @@ To configure without a global install, run the guided setup directly through npm
293
295
  npx --yes --ignore-scripts --package=sap-ai-dev-toolkit sap-ai-dev --setup --npx
294
296
  ```
295
297
 
296
- 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). It writes MCP entries that launch the selected servers through npx. The entries pin the package version used during setup, and `--ignore-scripts` avoids running the install-time wizard a second time. After setup, in BAS run **MCP: List Servers**, select each chosen destination, and choose **Start Server**.
298
+ 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). It writes MCP entries that launch the selected servers through npx. The entries pin the package version used during setup, and `--ignore-scripts` avoids running the install-time wizard a second time. After setup, in BAS or VS Code run **MCP: List Servers**, select each chosen destination, and choose **Start Server**.
297
299
 
298
300
  For a non-interactive installation or a platform without a published VSP asset, provide a trusted binary override:
299
301
 
@@ -747,6 +749,11 @@ The response contains a `tools` array. A `RunQuery` entry resembles this excerpt
747
749
  | `SAP_AI_DEV_TOOLKIT_DESTINATION` | Comma-separated destination allowlist for normal runtime discovery. Setup clears this temporarily so it can display all eligible systems. |
748
750
  | `SAP_AI_DEV_TOOLKIT_MODE` | VSP child mode (`expert` by default; `focused` omits `ActivateMultiple`, `GetUserTransports`, and `GetTransportInfo`). The proxy exposes its curated tool subset and local workflow tools, including `LintABAP`. |
749
751
  | `SAP_ALLOW_TRANSPORTABLE_EDITS` | Generated MCP entries set this to `true` to permit source edits in transportable packages; VSP safety checks and SAP authorizations still apply. |
752
+ | `SAP_AUTH_MODE=windows-sso` | Local SAP GUI entry mode for experimental Windows Integrated Authentication. Runtime starts a loopback ADT proxy and does not pass SAP username/password credentials to VSP. |
753
+ | `SAP_TLS_SERVER_NAMES` / `SAP_AI_DEV_TOOLKIT_TLS_SERVER_NAMES` | Comma-separated local SAP GUI TLS self-healing names, normally auto-discovered during setup from the SAP HTTPS certificate when `SAP_URL` uses an IP address. Runtime tries them one by one while keeping certificate validation enabled. |
754
+ | `SAP_TLS_SERVER_NAME` / `SAP_AI_DEV_TOOLKIT_TLS_SERVER_NAME` | Single-name compatibility form of the local SAP GUI TLS self-healing override. Prefer the auto-discovered plural variable written by setup. |
755
+ | `SAP_TLS_CA_FILE` / `SAP_AI_DEV_TOOLKIT_TLS_CA_FILE` | Optional PEM CA bundle for the local TLS self-healing proxy when the SAP HTTPS certificate chains to a private CA that is not in the OS/Node trust store. Certificate verification remains enabled. |
756
+ | `SAP_AI_DEV_TOOLKIT_NEGOTIATE_HELPER` / `SAP_AI_DEV_TOOLKIT_SSPI_HELPER` | Optional trusted Windows helper executable used by the local Windows SSO proxy to convert an HTTP `WWW-Authenticate: Negotiate` challenge into a Kerberos/SPNEGO token. NTLM fallback is rejected because it is connection-oriented and unsafe for this proxy shape. When unset on Windows, the bundled PowerShell SSPI helper is used. A custom helper receives one JSON line on stdin (`url`, `challenge`, `mechanism`) and prints the base64 token on stdout. |
750
757
  | `SAP_AI_DEV_TOOLKIT_HTTP_PROXY` | Egress proxy for BAS discovery and probe traffic. Takes precedence over `HTTP_PROXY`/`http_proxy`; unset means the default BAS proxy for `.dest` hosts, empty means direct. |
751
758
  | `SAP_AI_DEV_TOOLKIT_REQUEST_TIMEOUT_MS` | Per-request timeout for calls forwarded to a VSP child (default 600000 = 10 minutes; `0` disables). A stalled request fails with a timeout error; the child is left running. |
752
759
  | `SAP_AI_DEV_MCP_CONFIG` | Explicit MCP configuration path; highest precedence. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sap-ai-dev-toolkit",
3
- "version": "0.7.8",
3
+ "version": "0.8.0",
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",
@@ -88,7 +88,9 @@ async function installSelectedHarnessAssets(ids) {
88
88
  }
89
89
  for (const assets of results) {
90
90
  const installed = assets.installed + assets.updated;
91
- await announce(`${assets.label}: ${installed} file${installed === 1 ? '' : 's'} installed or updated in ${assets.root}; ${assets.unchanged} already current.`, 'success');
91
+ const removed = assets.removed || 0;
92
+ const cleanup = removed > 0 ? `; ${removed} duplicate or legacy file${removed === 1 ? '' : 's'} removed` : '';
93
+ await announce(`${assets.label}: ${installed} file${installed === 1 ? '' : 's'} installed or updated in ${assets.root}; ${assets.unchanged} already current${cleanup}.`, 'success');
92
94
  if (assets.conflicts.length > 0) {
93
95
  const paths = assets.conflicts.map(path => join(assets.root, path)).join(', ');
94
96
  await announce(`${assets.label}: existing customizations were preserved; review these paths: ${paths}`, 'warning');
@@ -152,6 +154,11 @@ async function runHarnessAssetInstall() {
152
154
  async function wireHarnessMcpServers(ids, servers) {
153
155
  const entries = servers && Object.keys(servers).length ? servers : null;
154
156
  if (!entries || !ids?.length) return;
157
+ const hasLocalSapGui = Object.values(entries).some(entry => entry?.env?.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'sap-gui-local');
158
+ if (hasLocalSapGui) {
159
+ await announce('Local SAP GUI MCP entries use VS Code input prompts for SAP login, so automatic wiring into non-VS Code harness MCP files was skipped. Configure those harnesses manually if they support secure runtime prompts.', 'info');
160
+ return;
161
+ }
155
162
  const { results, failures } = await installMcpServersForHarnesses(ids, entries, { env: runtimeEnv });
156
163
  for (const result of results) {
157
164
  if (!result.supported) {
@@ -190,7 +197,7 @@ export function destinationTable(destinations, registeredNames) {
190
197
  const registered = registeredNames.has(generatedServerName(destination.serverName || destination.name));
191
198
  const source = destination.source === 'cloud-foundry'
192
199
  ? `CF ${destination.cf?.destinationInstanceName || 'unknown instance'}`
193
- : 'BAS';
200
+ : (destination.source === 'sap-gui-local' ? 'SAP GUI' : 'BAS');
194
201
  return [
195
202
  { text: destination.name, output: destination.name },
196
203
  { text: source, output: source },
@@ -242,9 +249,10 @@ async function announceSetup(result) {
242
249
  const registeredNames = new Set(servers.map(([name]) => name));
243
250
  const entryDetails = servers.flatMap(([name, entry]) => {
244
251
  const destination = destinationsByServer.get(name);
245
- const source = brandedEnvValue(entry.env, 'DESTINATION_SOURCE') === 'cloud-foundry' || destination?.source === 'cloud-foundry'
252
+ const configuredSource = brandedEnvValue(entry.env, 'DESTINATION_SOURCE') || destination?.source;
253
+ const source = configuredSource === 'cloud-foundry'
246
254
  ? 'Cloud Foundry'
247
- : 'BAS';
255
+ : (configuredSource === 'sap-gui-local' ? 'Local SAP GUI' : 'BAS');
248
256
  const destinationName = brandedEnvValue(entry.env, 'DESTINATION') || destination?.name || 'unknown';
249
257
  const client = destination?.client || '001';
250
258
  const authentication = destination?.authentication || 'unknown';
@@ -0,0 +1,182 @@
1
+ # Windows Integrated Authentication helper for sap-ai-dev-toolkit.
2
+ # Reads one JSON object from stdin with: url, challenge, mechanism.
3
+ # Writes only the base64 Negotiate token to stdout.
4
+ # Errors are written to stderr and exit non-zero.
5
+
6
+ $ErrorActionPreference = 'Stop'
7
+
8
+ $sspiSource = @'
9
+ using System;
10
+ using System.ComponentModel;
11
+ using System.Runtime.InteropServices;
12
+
13
+ public static class SapAiDevSspiNegotiate
14
+ {
15
+ const int SECPKG_CRED_OUTBOUND = 2;
16
+ const int SECBUFFER_VERSION = 0;
17
+ const int SECBUFFER_TOKEN = 2;
18
+ const int SECURITY_NATIVE_DREP = 0x10;
19
+ const int SEC_E_OK = 0;
20
+ const int SEC_I_CONTINUE_NEEDED = 0x00090312;
21
+
22
+ [StructLayout(LayoutKind.Sequential)]
23
+ struct SecHandle
24
+ {
25
+ public IntPtr dwLower;
26
+ public IntPtr dwUpper;
27
+ }
28
+
29
+ [StructLayout(LayoutKind.Sequential)]
30
+ struct SecBuffer
31
+ {
32
+ public int cbBuffer;
33
+ public int BufferType;
34
+ public IntPtr pvBuffer;
35
+ }
36
+
37
+ [StructLayout(LayoutKind.Sequential)]
38
+ struct SecBufferDesc
39
+ {
40
+ public int ulVersion;
41
+ public int cBuffers;
42
+ public IntPtr pBuffers;
43
+ }
44
+
45
+ [StructLayout(LayoutKind.Sequential)]
46
+ struct SECURITY_INTEGER
47
+ {
48
+ public uint LowPart;
49
+ public int HighPart;
50
+ }
51
+
52
+ [DllImport("secur32.dll", CharSet = CharSet.Unicode, SetLastError = false)]
53
+ static extern int AcquireCredentialsHandle(
54
+ string pszPrincipal,
55
+ string pszPackage,
56
+ int fCredentialUse,
57
+ IntPtr pvLogonID,
58
+ IntPtr pAuthData,
59
+ IntPtr pGetKeyFn,
60
+ IntPtr pvGetKeyArgument,
61
+ out SecHandle phCredential,
62
+ out SECURITY_INTEGER ptsExpiry);
63
+
64
+ [DllImport("secur32.dll", CharSet = CharSet.Unicode, SetLastError = false)]
65
+ static extern int InitializeSecurityContext(
66
+ ref SecHandle phCredential,
67
+ IntPtr phContext,
68
+ string pszTargetName,
69
+ int fContextReq,
70
+ int Reserved1,
71
+ int TargetDataRep,
72
+ IntPtr pInput,
73
+ int Reserved2,
74
+ out SecHandle phNewContext,
75
+ IntPtr pOutput,
76
+ out int pfContextAttr,
77
+ out SECURITY_INTEGER ptsExpiry);
78
+
79
+ [DllImport("secur32.dll", SetLastError = false)]
80
+ static extern int FreeCredentialsHandle(ref SecHandle phCredential);
81
+
82
+ [DllImport("secur32.dll", SetLastError = false)]
83
+ static extern int DeleteSecurityContext(ref SecHandle phContext);
84
+
85
+ static void Check(int status, string operation)
86
+ {
87
+ if (status == SEC_E_OK || status == SEC_I_CONTINUE_NEEDED) return;
88
+ throw new Win32Exception(status, operation + " failed with SSPI status 0x" + status.ToString("X8"));
89
+ }
90
+
91
+ static IntPtr BufferDesc(byte[] token, out IntPtr tokenMemory, out IntPtr bufferMemory)
92
+ {
93
+ tokenMemory = IntPtr.Zero;
94
+ SecBuffer buffer = new SecBuffer();
95
+ buffer.BufferType = SECBUFFER_TOKEN;
96
+ if (token != null && token.Length > 0)
97
+ {
98
+ tokenMemory = Marshal.AllocHGlobal(token.Length);
99
+ Marshal.Copy(token, 0, tokenMemory, token.Length);
100
+ buffer.cbBuffer = token.Length;
101
+ buffer.pvBuffer = tokenMemory;
102
+ }
103
+ bufferMemory = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(SecBuffer)));
104
+ Marshal.StructureToPtr(buffer, bufferMemory, false);
105
+ SecBufferDesc desc = new SecBufferDesc();
106
+ desc.ulVersion = SECBUFFER_VERSION;
107
+ desc.cBuffers = 1;
108
+ desc.pBuffers = bufferMemory;
109
+ IntPtr descMemory = Marshal.AllocHGlobal(Marshal.SizeOf(typeof(SecBufferDesc)));
110
+ Marshal.StructureToPtr(desc, descMemory, false);
111
+ return descMemory;
112
+ }
113
+
114
+ static void FreeDesc(IntPtr descMemory, IntPtr tokenMemory, IntPtr bufferMemory)
115
+ {
116
+ if (descMemory != IntPtr.Zero) Marshal.FreeHGlobal(descMemory);
117
+ if (bufferMemory != IntPtr.Zero) Marshal.FreeHGlobal(bufferMemory);
118
+ if (tokenMemory != IntPtr.Zero) Marshal.FreeHGlobal(tokenMemory);
119
+ }
120
+
121
+ public static string CreateToken(string targetName, string incomingBase64)
122
+ {
123
+ SecHandle credential;
124
+ SECURITY_INTEGER expiry;
125
+ int status = AcquireCredentialsHandle(null, "Negotiate", SECPKG_CRED_OUTBOUND, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, out credential, out expiry);
126
+ Check(status, "AcquireCredentialsHandle");
127
+
128
+ SecHandle context = new SecHandle();
129
+ IntPtr inputDesc = IntPtr.Zero, inputToken = IntPtr.Zero, inputBuffer = IntPtr.Zero;
130
+ IntPtr outputDesc = IntPtr.Zero, outputToken = IntPtr.Zero, outputBuffer = IntPtr.Zero;
131
+ try
132
+ {
133
+ byte[] incoming = null;
134
+ if (!String.IsNullOrWhiteSpace(incomingBase64)) incoming = Convert.FromBase64String(incomingBase64);
135
+ if (incoming != null && incoming.Length > 0) inputDesc = BufferDesc(incoming, out inputToken, out inputBuffer);
136
+
137
+ outputDesc = BufferDesc(new byte[12288], out outputToken, out outputBuffer);
138
+ int attrs;
139
+ status = InitializeSecurityContext(ref credential, IntPtr.Zero, targetName, 0, 0, SECURITY_NATIVE_DREP, inputDesc, 0, out context, outputDesc, out attrs, out expiry);
140
+ Check(status, "InitializeSecurityContext");
141
+
142
+ SecBuffer outBuffer = (SecBuffer) Marshal.PtrToStructure(outputBuffer, typeof(SecBuffer));
143
+ if (outBuffer.cbBuffer <= 0 || outBuffer.pvBuffer == IntPtr.Zero) throw new Exception("SSPI returned an empty Negotiate token.");
144
+ byte[] bytes = new byte[outBuffer.cbBuffer];
145
+ Marshal.Copy(outBuffer.pvBuffer, bytes, 0, bytes.Length);
146
+ return Convert.ToBase64String(bytes);
147
+ }
148
+ finally
149
+ {
150
+ FreeDesc(inputDesc, inputToken, inputBuffer);
151
+ FreeDesc(outputDesc, outputToken, outputBuffer);
152
+ DeleteSecurityContext(ref context);
153
+ FreeCredentialsHandle(ref credential);
154
+ }
155
+ }
156
+ }
157
+ '@
158
+
159
+ try {
160
+ $raw = [Console]::In.ReadToEnd()
161
+ if ([string]::IsNullOrWhiteSpace($raw)) { throw 'Missing helper request on stdin.' }
162
+ $request = $raw | ConvertFrom-Json
163
+ if (-not $request.url) { throw 'Missing target URL.' }
164
+
165
+ $uri = [Uri] [string] $request.url
166
+ if ($uri.Scheme -ne 'http' -and $uri.Scheme -ne 'https') { throw 'Target URL must use HTTP or HTTPS.' }
167
+ if ($uri.UserInfo) { throw 'Target URL must not contain user information.' }
168
+
169
+ Add-Type -TypeDefinition $sspiSource -Language CSharp
170
+
171
+ $incoming = $null
172
+ $challenge = [string] $request.challenge
173
+ if ($challenge -match '(?i)Negotiate\s+([^,\s]+)') { $incoming = $Matches[1] }
174
+ $targetName = 'HTTP/' + $uri.DnsSafeHost
175
+ $token = [SapAiDevSspiNegotiate]::CreateToken($targetName, $incoming)
176
+ if ([string]::IsNullOrWhiteSpace($token)) { throw 'Negotiate token generation returned an empty token.' }
177
+ [Console]::Out.WriteLine($token.Trim())
178
+ exit 0
179
+ } catch {
180
+ [Console]::Error.WriteLine($_.Exception.Message)
181
+ exit 1
182
+ }
@@ -69,17 +69,43 @@ async function planDestinations(harness, files) {
69
69
  for (const file of files) {
70
70
  const raw = await readFile(file.source);
71
71
  if (!file.target.startsWith(`agents${sep}`)) {
72
- destinations.push({ target: file.target, content: raw });
72
+ destinations.push({ target: file.target, content: raw, aliases: [] });
73
73
  continue;
74
74
  }
75
75
  if (!harness.supportsAgents) continue;
76
- const fileName = harness.agentFileName ? harness.agentFileName(basename(file.target)) : basename(file.target);
76
+ const sourceName = basename(file.target);
77
+ const fileName = harness.agentFileName ? harness.agentFileName(sourceName) : sourceName;
78
+ const prefix = harness.agentTargetPrefix || 'agents';
79
+ const target = join(prefix, fileName);
80
+ const aliasCandidates = [
81
+ file.target,
82
+ join(prefix, sourceName),
83
+ join('agents', fileName)
84
+ ];
85
+ const aliases = [...new Set(aliasCandidates.filter(alias => alias !== target))];
77
86
  const content = harness.agentDocument ? Buffer.from(harness.agentDocument(raw.toString('utf8')), 'utf8') : raw;
78
- destinations.push({ target: join(harness.agentTargetPrefix || 'agents', fileName), content });
87
+ destinations.push({ target, content, aliases });
79
88
  }
80
89
  return destinations;
81
90
  }
82
91
 
92
+ async function reconcileRemovableFile({ targetRoot, target, contentHash, previousFiles }) {
93
+ const path = join(targetRoot, target);
94
+ let existing;
95
+ try {
96
+ existing = await readFile(path);
97
+ } catch (error) {
98
+ if (error.code === 'ENOENT') return 'missing';
99
+ throw error;
100
+ }
101
+ const existingHash = digest(existing);
102
+ if (previousFiles[target] === existingHash || existingHash === contentHash) {
103
+ await rm(path, { force: true });
104
+ return 'removed';
105
+ }
106
+ return 'conflict';
107
+ }
108
+
83
109
  export async function installUserAssets({ harness, home = process.env.HOME || homedir(), root = packageRoot, env = process.env } = {}) {
84
110
  const destinations = await planDestinations(harness, await collectSourceFiles(root));
85
111
  const targetRoot = harnessRoot(harness, { home, env });
@@ -89,15 +115,22 @@ export async function installUserAssets({ harness, home = process.env.HOME || ho
89
115
  const legacyFiles = harness.legacyManifest ? await readManagedFiles(legacyManifestPath) : {};
90
116
  const currentFiles = await readManagedFiles(manifestPath);
91
117
  const previousFiles = { ...legacyFiles, ...currentFiles };
92
- const managedFiles = { ...previousFiles };
118
+ const managedFiles = {};
119
+ const activeTargets = new Set(destinations.flatMap(file => [file.target, ...file.aliases]));
93
120
  const conflicts = [];
94
121
  let installed = 0;
95
122
  let updated = 0;
96
123
  let unchanged = 0;
124
+ let removed = 0;
97
125
 
98
126
  for (const file of destinations) {
99
127
  const destination = join(targetRoot, file.target);
100
128
  const contentHash = digest(file.content);
129
+ for (const alias of file.aliases) {
130
+ const status = await reconcileRemovableFile({ targetRoot, target: alias, contentHash, previousFiles });
131
+ if (status === 'removed') removed += 1;
132
+ if (status === 'conflict') conflicts.push(alias);
133
+ }
101
134
  await mkdir(dirname(destination), { recursive: true, mode: 0o755 });
102
135
 
103
136
  let existing;
@@ -129,9 +162,16 @@ export async function installUserAssets({ harness, home = process.env.HOME || ho
129
162
  }
130
163
  }
131
164
 
165
+ for (const target of Object.keys(previousFiles)) {
166
+ if (activeTargets.has(target) || managedFiles[target]) continue;
167
+ const status = await reconcileRemovableFile({ targetRoot, target, contentHash: '', previousFiles });
168
+ if (status === 'removed') removed += 1;
169
+ if (status === 'conflict') conflicts.push(target);
170
+ }
171
+
132
172
  await replaceFile(manifestPath, `${JSON.stringify({ version: 1, files: managedFiles }, null, 2)}\n`);
133
173
  if (harness.legacyManifest) await rm(legacyManifestPath, { force: true });
134
- return { harness: harness.id, label: harness.label, root: targetRoot, installed, updated, unchanged, conflicts };
174
+ return { harness: harness.id, label: harness.label, root: targetRoot, installed, updated, unchanged, removed, conflicts };
135
175
  }
136
176
 
137
177
  export async function installUserAssetsForHarnesses(ids, { home, root = packageRoot, env = process.env } = {}) {
package/src/launcher.mjs CHANGED
@@ -4,11 +4,14 @@ 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
8
  import { findBinary } from './binary.mjs';
8
9
  import { MCPProxy } from './mcp-proxy.mjs';
9
10
  import { installMcpConfig, repairManagedMcpConfig } from './mcp-config.mjs';
10
11
  import { runSetup } from './setup.mjs';
11
12
  import { resolveConfiguredCloudFoundryDestination } from './cf-destination.mjs';
13
+ import { createWindowsSsoAdtProxy } from './windows-sso-adt-proxy.mjs';
14
+ import { createTlsServerNameAdtProxy } from './tls-adt-proxy.mjs';
12
15
  import { withBrandedEnvironment } from './branding.mjs';
13
16
  import { redactText } from './redact.mjs';
14
17
  const root = dirname(dirname(fileURLToPath(import.meta.url)));
@@ -25,7 +28,7 @@ function usage() {
25
28
  return [
26
29
  'Usage: sap-ai-dev [options]',
27
30
  '',
28
- 'With H2O_URL set and no command, starts the SAP AI Dev Toolkit stdio server for selected BAS destinations.',
31
+ 'With H2O_URL set, starts the SAP AI Dev Toolkit stdio server for selected BAS destinations; with local SAP GUI setup, starts the selected local ADT system.',
29
32
  'Options:',
30
33
  ' --setup Configure generated BAS MCP servers',
31
34
  ' --tools Also offer full-stack SAP companion MCP servers (use with --setup)',
@@ -161,9 +164,58 @@ async function binaryOrError() {
161
164
  async function discoverForCommand() {
162
165
  const env = { ...runtimeEnv };
163
166
  if (env.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'cloud-foundry') env.SAP_AI_DEV_TOOLKIT_DESTINATION = '';
167
+ if (!env.H2O_URL) {
168
+ const systems = await discoverSapGuiSystems({ env });
169
+ if (!systems.length) throw new Error('H2O_URL is required for BAS destination discovery');
170
+ return systems;
171
+ }
164
172
  return discoverDestinations({ env });
165
173
  }
166
174
 
175
+ async function configuredLocalSapGuiDestination(env) {
176
+ const name = env.SAP_AI_DEV_TOOLKIT_DESTINATION || env.SAP_SYSTEM_ID || 'local-sap';
177
+ if (!env.SAP_URL) throw new Error('SAP_URL is required for local SAP GUI MCP entries. Rerun sap-ai-dev --setup and enter the ADT URL.');
178
+ const authMode = String(env.SAP_AUTH_MODE || '').toLowerCase();
179
+ const tlsServerName = String(env.SAP_TLS_SERVER_NAME || env.SAP_AI_DEV_TOOLKIT_TLS_SERVER_NAME || '').trim();
180
+ const tlsServerNames = String(env.SAP_TLS_SERVER_NAMES || env.SAP_AI_DEV_TOOLKIT_TLS_SERVER_NAMES || tlsServerName).split(',').map(value => value.trim()).filter(Boolean);
181
+ const tlsCaFile = String(env.SAP_TLS_CA_FILE || env.SAP_AI_DEV_TOOLKIT_TLS_CA_FILE || '').trim();
182
+ const tlsRoute = tlsServerNames.length
183
+ ? await createTlsServerNameAdtProxy({ destinationUrl: env.SAP_URL, tlsServerNames, caFile: tlsCaFile || undefined })
184
+ : null;
185
+ const adtUrl = tlsRoute?.url || env.SAP_URL;
186
+ const closeRoute = async route => {
187
+ await Promise.all([route?.close?.(), tlsRoute?.close?.()].filter(Boolean).map(close => Promise.resolve().then(close).catch(() => {})));
188
+ };
189
+ if (authMode === 'windows-sso') {
190
+ const route = await createWindowsSsoAdtProxy({ destinationUrl: adtUrl, env });
191
+ return {
192
+ source: 'sap-gui-local',
193
+ name,
194
+ url: route.url,
195
+ backendUrl: env.SAP_URL,
196
+ client: env.SAP_CLIENT || '001',
197
+ authentication: 'WindowsSSO',
198
+ childEnv: {},
199
+ close: () => closeRoute(route),
200
+ probe: { status: tlsRoute ? 'configured-tls-server-name' : 'configured', available: true }
201
+ };
202
+ }
203
+ return {
204
+ source: 'sap-gui-local',
205
+ name,
206
+ url: adtUrl,
207
+ backendUrl: tlsRoute ? env.SAP_URL : undefined,
208
+ client: env.SAP_CLIENT || '001',
209
+ authentication: 'Basic',
210
+ childEnv: {
211
+ SAP_USER: env.SAP_USER || env.SAP_USERNAME || '',
212
+ SAP_PASSWORD: env.SAP_PASSWORD || env.SAP_PASS || ''
213
+ },
214
+ close: tlsRoute?.close,
215
+ probe: { status: tlsRoute ? 'configured-tls-server-name' : 'configured', available: true }
216
+ };
217
+ }
218
+
167
219
  async function main() {
168
220
  const setup = hasFlag('--setup');
169
221
  const check = hasFlag('--check');
@@ -184,7 +236,9 @@ async function main() {
184
236
  try {
185
237
  const destinations = runtimeEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'cloud-foundry'
186
238
  ? [await resolveConfiguredCloudFoundryDestination({ env: runtimeEnv })]
187
- : await discoverForCommand();
239
+ : (runtimeEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'sap-gui-local'
240
+ ? [await configuredLocalSapGuiDestination(runtimeEnv)]
241
+ : await discoverForCommand());
188
242
  report = await runDoctor(destinations);
189
243
  } catch (error) {
190
244
  report = { ok: false, destinations: 0, checks: [doctorRow('-', 'destination discovery', 'failed', redactText(error.message || error).slice(0, 300))] };
@@ -219,8 +273,10 @@ async function main() {
219
273
  return;
220
274
  }
221
275
 
222
- if (runtimeEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'cloud-foundry') {
223
- const destination = await resolveConfiguredCloudFoundryDestination({ env: runtimeEnv });
276
+ if (['cloud-foundry', 'sap-gui-local'].includes(runtimeEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE)) {
277
+ const destination = runtimeEnv.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'cloud-foundry'
278
+ ? await resolveConfiguredCloudFoundryDestination({ env: runtimeEnv })
279
+ : await configuredLocalSapGuiDestination(runtimeEnv);
224
280
  let proxy;
225
281
  try {
226
282
  const binary = await binaryOrError();