sap-ai-dev-toolkit 0.2.5

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.
Files changed (57) hide show
  1. package/.github/agents/abap-developer.agent.md +25 -0
  2. package/.github/agents/abap-runtime-debugger.agent.md +27 -0
  3. package/.github/agents/rap-service-developer.agent.md +27 -0
  4. package/.github/agents/sap-solution-architect.agent.md +31 -0
  5. package/.github/skills/abap-debugging/SKILL.md +15 -0
  6. package/.github/skills/abap-development/SKILL.md +16 -0
  7. package/.github/skills/abap-runtime-analysis/SKILL.md +17 -0
  8. package/.github/skills/abap-testing-quality/SKILL.md +19 -0
  9. package/.github/skills/cds-development/SKILL.md +16 -0
  10. package/.github/skills/clean-core-extensibility/SKILL.md +16 -0
  11. package/.github/skills/rap-development/SKILL.md +16 -0
  12. package/.github/skills/rap-service-delivery/SKILL.md +17 -0
  13. package/.github/skills/sap-sdlc-orchestration/SKILL.md +16 -0
  14. package/.github/skills/sap-standard-api-analysis/SKILL.md +16 -0
  15. package/.github/skills/sap-transport-release/SKILL.md +14 -0
  16. package/LICENSE +22 -0
  17. package/LICENSE-APACHE-2.0.txt +201 -0
  18. package/NOTICE +33 -0
  19. package/README.md +723 -0
  20. package/dist/checksums.txt +5 -0
  21. package/dist/vsp-darwin-arm64 +0 -0
  22. package/dist/vsp-darwin-x64 +0 -0
  23. package/dist/vsp-linux-arm64 +0 -0
  24. package/dist/vsp-linux-x64 +0 -0
  25. package/dist/vsp-win32-x64.exe +0 -0
  26. package/package.json +52 -0
  27. package/patches/vsp-bas-proxy-auth.patch +310 -0
  28. package/scripts/build-vsp.mjs +52 -0
  29. package/scripts/ensure-go.mjs +59 -0
  30. package/scripts/install-user-copilot-assets.mjs +110 -0
  31. package/scripts/mutation-terminal-ui.mjs +69 -0
  32. package/scripts/postinstall.mjs +269 -0
  33. package/scripts/publish-npm.mjs +97 -0
  34. package/src/abaplint.mjs +105 -0
  35. package/src/bas-discovery.mjs +210 -0
  36. package/src/binary.mjs +99 -0
  37. package/src/cf-connectivity.mjs +317 -0
  38. package/src/cf-destination.mjs +793 -0
  39. package/src/launcher.mjs +144 -0
  40. package/src/mcp-config.mjs +253 -0
  41. package/src/mcp-proxy.mjs +388 -0
  42. package/src/setup.mjs +275 -0
  43. package/src/terminal-ui.mjs +67 -0
  44. package/test/cf-connectivity.test.mjs +287 -0
  45. package/test/cf-destination.test.mjs +307 -0
  46. package/test/cf-runtime.test.mjs +505 -0
  47. package/test/copilot-assets.test.mjs +37 -0
  48. package/test/copilot-content.test.mjs +145 -0
  49. package/test/discovery.test.mjs +199 -0
  50. package/test/fixtures/fake-vsp.mjs +244 -0
  51. package/test/launcher.test.mjs +164 -0
  52. package/test/mcp-config-cf.test.mjs +149 -0
  53. package/test/mcp-proxy.test.mjs +337 -0
  54. package/test/setup-cf.test.mjs +362 -0
  55. package/test/setup.test.mjs +376 -0
  56. package/test/terminal-ui.test.mjs +47 -0
  57. package/tools.md +68 -0
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { dirname, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { discoverDestinations, remediation, statusRows } from './bas-discovery.mjs';
7
+ import { findBinary } from './binary.mjs';
8
+ import { MCPProxy } from './mcp-proxy.mjs';
9
+ import { installMcpConfig } from './mcp-config.mjs';
10
+ import { runSetup } from './setup.mjs';
11
+ import { resolveConfiguredCloudFoundryDestination } from './cf-destination.mjs';
12
+ const root = dirname(dirname(fileURLToPath(import.meta.url)));
13
+ const pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
14
+
15
+ function hasFlag(name) { return process.argv.slice(2).includes(name); }
16
+ function json(value) { process.stdout.write(`${JSON.stringify(value)}\n`); }
17
+
18
+ function usage() {
19
+ return [
20
+ 'Usage: sap-ai-dev [options]',
21
+ '',
22
+ 'With H2O_URL set and no command, starts the BAS MCP stdio server.',
23
+ 'Options:',
24
+ ' --setup Configure generated BAS MCP servers',
25
+ ' --tools Also offer full-stack SAP companion MCP servers (use with --setup)',
26
+ ' --npx Launch configured MCP servers through the pinned npm package (use with --setup)',
27
+ ' --list-destinations List discovered BAS destinations',
28
+ ' --list-destinations --json Print redacted JSON status',
29
+ ' --check Probe destination availability',
30
+ ' --help, -h Show this help and exit',
31
+ '',
32
+ 'Use an MCP client to call server tools; the BAS runtime uses stdio for MCP.',
33
+ ''
34
+ ].join('\n');
35
+ }
36
+
37
+ function probeDiagnostic(destination) {
38
+ const probe = destination.probe || {};
39
+ const details = [];
40
+ if (probe.httpStatus) details.push(`HTTP ${probe.httpStatus}`);
41
+ if (probe.error) {
42
+ const error = String(probe.error)
43
+ .replace(/(authorization|cookie|password|secret|token)\s*[:=]\s*[^\s,;]+/gi, '$1=[redacted]')
44
+ .replace(/\s+/g, ' ')
45
+ .slice(0, 400);
46
+ details.push(error);
47
+ }
48
+ return `[sap-ai-dev] ${destination.name}: probe=${probe.status || 'unknown'}${details.length ? ` (${details.join('; ')})` : ''}`;
49
+ }
50
+
51
+ async function binaryOrError() {
52
+ const path = await findBinary(pkg);
53
+ if (path) return path;
54
+ throw new Error('VSP binary is not installed. Reinstall with scripts enabled, set BAS_VSP_BINARY to a trusted patched binary, or run npm run build:vsp.');
55
+ }
56
+
57
+ async function discoverForCommand() {
58
+ const env = { ...process.env };
59
+ if (env.BAS_VSP_DESTINATION_SOURCE === 'cloud-foundry') env.BAS_VSP_DESTINATION = '';
60
+ return discoverDestinations({ env });
61
+ }
62
+
63
+ async function main() {
64
+ const setup = hasFlag('--setup');
65
+ const check = hasFlag('--check');
66
+ const list = hasFlag('--list-destinations');
67
+ if (hasFlag('--help') || hasFlag('-h')) {
68
+ process.stdout.write(usage());
69
+ return;
70
+ }
71
+ if (setup) {
72
+ const setupOptions = {
73
+ includeSapDevelopmentToolsPrompt: hasFlag('--tools') || hasFlag('--companion-tools')
74
+ };
75
+ if (hasFlag('--npx')) {
76
+ setupOptions.install = (selected, options) => installMcpConfig(selected, {
77
+ ...options,
78
+ command: 'npx',
79
+ args: ['--yes', '--ignore-scripts', `--package=sap-ai-dev-toolkit@${pkg.version}`, 'sap-ai-dev']
80
+ });
81
+ }
82
+ await runSetup(setupOptions);
83
+ return;
84
+ }
85
+ if (check || list) {
86
+ const destinations = await discoverForCommand();
87
+ const statuses = statusRows(destinations);
88
+ if (list && hasFlag('--json')) json(statuses);
89
+ else for (const row of statuses) console.error(`${row.name}: client=${row.client} authentication=${row.authentication} probe=${row.probe}`);
90
+ if (check && !destinations.some(destination => destination.probe?.available)) {
91
+ throw new Error('No BAS destination answered the ADT discovery probe successfully. Review the probe results above.');
92
+ }
93
+ return;
94
+ }
95
+
96
+ if (process.env.BAS_VSP_DESTINATION_SOURCE === 'cloud-foundry') {
97
+ const destination = await resolveConfiguredCloudFoundryDestination({ env: process.env });
98
+ let proxy;
99
+ try {
100
+ const binary = await binaryOrError();
101
+ proxy = new MCPProxy({ binary, destinations: [destination], env: process.env, log: message => console.error(message) });
102
+ const shutdown = signal => { void proxy.close().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143)); };
103
+ process.once('SIGINT', () => shutdown('SIGINT'));
104
+ process.once('SIGTERM', () => shutdown('SIGTERM'));
105
+ await proxy.serve(process.stdin);
106
+ } catch (error) {
107
+ await proxy?.close();
108
+ await destination.close?.();
109
+ throw error;
110
+ }
111
+ return;
112
+ }
113
+
114
+ if (!process.env.H2O_URL) {
115
+ const binary = await binaryOrError();
116
+ const child = spawn(binary, process.argv.slice(2), { env: process.env, stdio: 'inherit' });
117
+ process.once('SIGINT', () => child.kill('SIGINT'));
118
+ process.once('SIGTERM', () => child.kill('SIGTERM'));
119
+ const [code, signal] = await new Promise(resolve => child.once('exit', (exitCode, exitSignal) => resolve([exitCode, exitSignal])));
120
+ if (signal) process.kill(process.pid, signal);
121
+ process.exitCode = code ?? 1;
122
+ return;
123
+ }
124
+
125
+ const discovered = await discoverDestinations({ env: process.env });
126
+ for (const destination of discovered) console.error(probeDiagnostic(destination));
127
+ const destinations = discovered;
128
+ if (!destinations.length) {
129
+ console.error('[sap-ai-dev] destination discovery returned no named BAS destinations');
130
+ throw new Error(remediation);
131
+ }
132
+ const binary = await binaryOrError();
133
+ console.error(`[sap-ai-dev] starting MCP proxy for ${destinations.map(destination => `${destination.name} (client=${destination.client})`).join(', ')}`);
134
+ const proxy = new MCPProxy({ binary, destinations, env: process.env, log: message => console.error(message) });
135
+ const shutdown = signal => { void proxy.close().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143)); };
136
+ process.once('SIGINT', () => shutdown('SIGINT'));
137
+ process.once('SIGTERM', () => shutdown('SIGTERM'));
138
+ await proxy.serve(process.stdin);
139
+ }
140
+
141
+ main().catch(error => {
142
+ console.error(`sap-ai-dev: ${error.message}`);
143
+ process.exitCode = 1;
144
+ });
@@ -0,0 +1,253 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { basename, dirname, join } from 'node:path';
5
+
6
+ const LEGACY_MCP_SERVER_PREFIXES = ['sapAiDev_', 'basVspMcp_'];
7
+ const COMPANION_SERVER_KIND = 'sap-development-companion';
8
+
9
+ export const SAP_DEVELOPMENT_MCP_SERVERS = Object.freeze([
10
+ Object.freeze({
11
+ id: 'sap-fiori-tools',
12
+ name: 'SAP Fiori tools',
13
+ description: 'SAP Fiori elements/freestyle project generation, annotations, and UX guidance.',
14
+ packageName: '@sap-ux/fiori-mcp-server',
15
+ bin: 'fiori-mcp',
16
+ priority: 'recommended'
17
+ }),
18
+ Object.freeze({
19
+ id: 'ui5-tools',
20
+ name: 'UI5 tools',
21
+ description: 'SAPUI5/OpenUI5 project inspection, UI5-aware help, and lint/project support.',
22
+ packageName: '@ui5/mcp-server',
23
+ bin: 'ui5mcp',
24
+ priority: 'recommended'
25
+ }),
26
+ Object.freeze({
27
+ id: 'cap-tools',
28
+ name: 'CAP tools',
29
+ description: 'CAP CDS/service model inspection and AI-assisted CAP application development.',
30
+ packageName: '@cap-js/mcp-server',
31
+ bin: 'cds-mcp',
32
+ priority: 'recommended'
33
+ }),
34
+ Object.freeze({
35
+ id: 'browser-validation',
36
+ name: 'Browser validation',
37
+ description: 'Playwright browser automation for Fiori/UI smoke tests, screenshots, and runtime checks.',
38
+ packageName: '@playwright/mcp',
39
+ bin: 'playwright-mcp',
40
+ priority: 'recommended'
41
+ })
42
+ ]);
43
+
44
+ async function exists(path) {
45
+ try {
46
+ await stat(path);
47
+ return true;
48
+ } catch (error) {
49
+ if (error?.code === 'ENOENT') return false;
50
+ throw error;
51
+ }
52
+ }
53
+
54
+ export async function resolveMcpConfigPath(env = process.env) {
55
+ if (env.SAP_AI_DEV_MCP_CONFIG) return env.SAP_AI_DEV_MCP_CONFIG;
56
+ if (env.BAS_VSP_MCP_CONFIG) return env.BAS_VSP_MCP_CONFIG;
57
+ const home = env.HOME || homedir();
58
+ const candidates = [
59
+ join(home, '.vscode', 'data', 'User', 'mcp.json'),
60
+ join(home, '.vscode-server', 'data', 'User', 'mcp.json'),
61
+ join(home, '.code-server', 'data', 'User', 'mcp.json')
62
+ ];
63
+ for (const candidate of candidates) if (await exists(candidate)) return candidate;
64
+ return candidates[0];
65
+ }
66
+
67
+ export function generatedServerName(destinationName) {
68
+ return String(destinationName);
69
+ }
70
+
71
+ export function buildSapDevelopmentMcpEntries(serverIds = SAP_DEVELOPMENT_MCP_SERVERS.map(server => server.id), { packageVersions = {}, packageManager = 'npx' } = {}) {
72
+ const selected = new Set(serverIds);
73
+ const entries = Object.create(null);
74
+ for (const server of SAP_DEVELOPMENT_MCP_SERVERS) {
75
+ if (!selected.has(server.id)) continue;
76
+ const packageSpec = packageVersions[server.id] || packageVersions[server.packageName] || server.packageName;
77
+ entries[server.id] = {
78
+ type: 'stdio',
79
+ command: packageManager,
80
+ args: ['--yes', `--package=${packageSpec}`, server.bin],
81
+ BAS_EXT: 'true',
82
+ BAS_EXT_KIND: COMPANION_SERVER_KIND,
83
+ displayName: server.name,
84
+ description: server.description
85
+ };
86
+ }
87
+ const unknown = [...selected].filter(id => !SAP_DEVELOPMENT_MCP_SERVERS.some(server => server.id === id));
88
+ if (unknown.length) throw new Error(`Unknown SAP development MCP server id${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}`);
89
+ return entries;
90
+ }
91
+
92
+ export function buildMcpEntries(destinations, env = process.env) {
93
+ const h2oUrl = env.H2O_URL;
94
+ if (!h2oUrl) throw new Error('H2O_URL is required to write BAS MCP configuration');
95
+ const entries = Object.create(null);
96
+ const selected = [...destinations].sort((a, b) => String(a.serverName || a.name).localeCompare(String(b.serverName || b.name)));
97
+ for (const destination of selected) {
98
+ const isCf = destination.source === 'cloud-foundry';
99
+ const name = generatedServerName(isCf ? destination.serverName : destination.name);
100
+ if (Object.hasOwn(entries, name)) throw new Error(`Duplicate MCP destination server name: ${name}`);
101
+ const entryEnv = {
102
+ H2O_URL: String(h2oUrl),
103
+ SAP_ALLOW_TRANSPORTABLE_EDITS: 'true'
104
+ };
105
+ if (isCf) {
106
+ const cf = destination.cf;
107
+ if (!cf) throw new Error(`Cloud Foundry destination "${destination.name}" is missing service references`);
108
+ Object.assign(entryEnv, {
109
+ BAS_VSP_DESTINATION_SOURCE: 'cloud-foundry',
110
+ BAS_VSP_DESTINATION: String(destination.name),
111
+ BAS_CF_SPACE_GUID: String(cf.spaceGuid),
112
+ BAS_CF_DESTINATION_INSTANCE_GUID: String(cf.destinationInstanceGuid),
113
+ BAS_CF_DESTINATION_INSTANCE: String(cf.destinationInstanceName),
114
+ BAS_CF_DESTINATION_KEY: String(cf.destinationKeyName),
115
+ BAS_CF_DESTINATION_NAME: String(destination.name)
116
+ });
117
+ if (destination.proxyType?.toLowerCase() === 'onpremise') {
118
+ Object.assign(entryEnv, {
119
+ BAS_CF_CONNECTIVITY_INSTANCE_GUID: String(cf.connectivityInstanceGuid),
120
+ BAS_CF_CONNECTIVITY_INSTANCE: String(cf.connectivityInstanceName),
121
+ BAS_CF_CONNECTIVITY_KEY: String(cf.connectivityKeyName)
122
+ });
123
+ }
124
+ } else {
125
+ entryEnv.BAS_VSP_DESTINATION = String(destination.name);
126
+ }
127
+ entries[name] = {
128
+ type: 'stdio',
129
+ command: 'sap-ai-dev',
130
+ env: entryEnv,
131
+ BAS_EXT: 'true'
132
+ };
133
+ }
134
+ return entries;
135
+ }
136
+
137
+ function isCompanionServer(entry) {
138
+ return entry?.BAS_EXT === 'true' && entry?.BAS_EXT_KIND === COMPANION_SERVER_KIND;
139
+ }
140
+
141
+ function isPackageLauncher(entry) {
142
+ const commands = new Set(['sap-ai-dev', 'bas-vsp-mcp']);
143
+ const packages = [/^--package=sap-ai-dev-toolkit(?:@[^/]+)?$/, /^--package=bas-mcp-addon(?:@[^/]+)?$/];
144
+ const npxLauncher = entry?.command === 'npx'
145
+ && Array.isArray(entry.args)
146
+ && entry.args.some(argument => commands.has(argument))
147
+ && entry.args.some(argument => typeof argument === 'string' && packages.some(pattern => pattern.test(argument)));
148
+ return entry?.BAS_EXT === 'true' && (commands.has(entry?.command) || npxLauncher);
149
+ }
150
+
151
+ function collectCloudFoundryKeyReferences(config, managedOnly) {
152
+ const refs = new Map();
153
+ for (const entry of Object.values(config?.servers || {})) {
154
+ const e = entry?.env;
155
+ if (!e || (managedOnly && (e.BAS_VSP_DESTINATION_SOURCE !== 'cloud-foundry' || !isPackageLauncher(entry)))) continue;
156
+ const candidates = [
157
+ { kind: 'destination', spaceGuid: e.BAS_CF_SPACE_GUID, instanceGuid: e.BAS_CF_DESTINATION_INSTANCE_GUID, instanceName: e.BAS_CF_DESTINATION_INSTANCE, keyName: e.BAS_CF_DESTINATION_KEY },
158
+ { kind: 'connectivity', spaceGuid: e.BAS_CF_SPACE_GUID, instanceGuid: e.BAS_CF_CONNECTIVITY_INSTANCE_GUID, instanceName: e.BAS_CF_CONNECTIVITY_INSTANCE, keyName: e.BAS_CF_CONNECTIVITY_KEY }
159
+ ];
160
+ for (const ref of candidates) {
161
+ if (!ref.spaceGuid || !ref.instanceGuid || !ref.instanceName || !ref.keyName) continue;
162
+ refs.set(`${ref.kind}\\0${ref.spaceGuid}\\0${ref.instanceGuid}\\0${ref.keyName}`, ref);
163
+ }
164
+ }
165
+ return [...refs.values()];
166
+ }
167
+
168
+ export function collectManagedCloudFoundryKeyReferences(config) {
169
+ return collectCloudFoundryKeyReferences(config, true);
170
+ }
171
+
172
+ export function collectCloudFoundryKeyReferencesFromAllEntries(config) {
173
+ return collectCloudFoundryKeyReferences(config, false);
174
+ }
175
+
176
+ async function readConfig(path) {
177
+ let raw;
178
+ try {
179
+ raw = await readFile(path, 'utf8');
180
+ } catch (error) {
181
+ if (error?.code === 'ENOENT') return { servers: {}, inputs: [] };
182
+ throw new Error(`MCP config ${path} could not be read: ${error.message}`);
183
+ }
184
+ let config;
185
+ try {
186
+ config = JSON.parse(raw);
187
+ } catch (error) {
188
+ throw new Error(`MCP config ${path} contains invalid JSON: ${error.message}`);
189
+ }
190
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
191
+ throw new Error(`MCP config ${path} must contain a top-level JSON object`);
192
+ }
193
+ if (config.servers !== undefined && (!config.servers || typeof config.servers !== 'object' || Array.isArray(config.servers))) {
194
+ throw new Error(`MCP config ${path} has a non-object servers value`);
195
+ }
196
+ return { ...config, servers: config.servers || {} };
197
+ }
198
+
199
+ async function writeConfig(path, config) {
200
+ const directory = dirname(path);
201
+ await mkdir(directory, { recursive: true });
202
+ const temporary = join(directory, `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
203
+ try {
204
+ await writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
205
+ await rename(temporary, path);
206
+ } catch (error) {
207
+ await rm(temporary, { force: true }).catch(() => {});
208
+ throw new Error(`MCP config ${path} could not be written: ${error.message}`);
209
+ }
210
+ }
211
+
212
+ export async function installMcpConfig(destinationsOrOptions, options = {}) {
213
+ let destinations = destinationsOrOptions;
214
+ if (!Array.isArray(destinationsOrOptions)) {
215
+ destinations = destinationsOrOptions?.destinations || [];
216
+ options = destinationsOrOptions || {};
217
+ }
218
+ const env = options.env || process.env;
219
+ const path = options.path || await resolveMcpConfigPath(env);
220
+ const config = await readConfig(path);
221
+ const generated = {
222
+ ...buildMcpEntries(destinations, env),
223
+ ...buildSapDevelopmentMcpEntries(options.sapDevelopmentServers || [])
224
+ };
225
+ if (options.command) {
226
+ for (const entry of Object.values(generated)) {
227
+ if (isCompanionServer(entry)) continue;
228
+ entry.command = options.command;
229
+ if (options.args?.length) entry.args = [...options.args];
230
+ }
231
+ }
232
+ const servers = Object.create(null);
233
+ for (const [name, entry] of Object.entries(config.servers)) {
234
+ const managed = isPackageLauncher(entry)
235
+ && typeof entry?.env?.BAS_VSP_DESTINATION === 'string'
236
+ && (entry.env.BAS_VSP_DESTINATION_SOURCE === 'cloud-foundry'
237
+ ? typeof entry.env.BAS_CF_DESTINATION_KEY === 'string'
238
+ : entry.env.BAS_VSP_DESTINATION_SOURCE === undefined);
239
+ if (!LEGACY_MCP_SERVER_PREFIXES.some(prefix => name.startsWith(prefix)) && !managed && !isCompanionServer(entry)) servers[name] = entry;
240
+ }
241
+ for (const name of Object.keys(generated)) {
242
+ if (Object.hasOwn(servers, name)) {
243
+ throw new Error(`MCP server "${name}" already exists and is not managed by sap-ai-dev-toolkit`);
244
+ }
245
+ }
246
+ config.servers = { ...servers, ...generated };
247
+ await writeConfig(path, config);
248
+ return { path, servers: generated };
249
+ }
250
+
251
+ export async function readMcpConfig(path) {
252
+ return readConfig(path);
253
+ }