sap-ai-dev-toolkit 0.2.7 → 0.3.1
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/.github/agents/abap-developer.agent.md +4 -3
- package/.github/agents/abap-runtime-debugger.agent.md +1 -1
- package/.github/agents/rap-service-developer.agent.md +1 -1
- package/.github/skills/abap-development/SKILL.md +2 -1
- package/.github/skills/rap-service-delivery/SKILL.md +4 -1
- package/.github/skills/sap-transport-release/SKILL.md +2 -2
- package/README.md +82 -34
- package/package.json +1 -1
- package/scripts/ensure-go.mjs +5 -4
- package/scripts/install-user-copilot-assets.mjs +6 -1
- package/scripts/postinstall.mjs +11 -11
- package/src/bas-discovery.mjs +3 -2
- package/src/binary.mjs +27 -17
- package/src/branding.mjs +21 -0
- package/src/cf-destination.mjs +1 -1
- package/src/demo-vsp.mjs +113 -0
- package/src/engineering-tools.mjs +641 -0
- package/src/launcher.mjs +118 -11
- package/src/mcp-config.mjs +19 -10
- package/src/mcp-proxy.mjs +18 -4
- package/src/setup.mjs +5 -6
- package/test/cf-destination.test.mjs +2 -2
- package/test/cf-runtime.test.mjs +12 -12
- package/test/copilot-assets.test.mjs +26 -0
- package/test/discovery.test.mjs +10 -1
- package/test/launcher.test.mjs +46 -9
- package/test/mcp-config-cf.test.mjs +49 -5
- package/test/mcp-proxy.test.mjs +1 -1
- package/test/pty.mjs +8 -0
- package/test/setup-cf.test.mjs +4 -4
- package/test/setup.test.mjs +51 -14
- package/tools.md +12 -1
package/src/binary.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import { chmod, mkdir, rename, stat, writeFile } from 'node:fs/promises';
|
|
|
3
3
|
import { homedir, tmpdir } from 'node:os';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { brandedEnvValue } from './branding.mjs';
|
|
6
7
|
|
|
7
8
|
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
8
9
|
export const UPSTREAM_COMMIT = '9886d2727f47506368b0a3c2f1c1766f1200f747';
|
|
@@ -17,7 +18,7 @@ const platformMap = {
|
|
|
17
18
|
|
|
18
19
|
export function binaryTarget(platform = process.platform, arch = process.arch) {
|
|
19
20
|
const target = platformMap[`${platform}:${arch}`];
|
|
20
|
-
if (!target) throw new Error(`Unsupported platform ${platform}/${arch}. Set
|
|
21
|
+
if (!target) throw new Error(`Unsupported platform ${platform}/${arch}. Set SAP_AI_DEV_TOOLKIT_BINARY or build cmd/vsp with scripts/build-vsp.mjs.`);
|
|
21
22
|
return { os: target[0], arch: target[1], extension: target[2], asset: `vsp-${target[0]}-${target[1]}${target[2]}` };
|
|
22
23
|
}
|
|
23
24
|
|
|
@@ -32,7 +33,7 @@ export function packageRepositoryUrl(pkg) {
|
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
export function releaseBaseUrl(pkg, version = pkg.version) {
|
|
35
|
-
const override = process.env
|
|
36
|
+
const override = brandedEnvValue(process.env, 'RELEASE_BASE_URL');
|
|
36
37
|
if (override) return `${override.replace(/\/$/, '')}/v${version}`;
|
|
37
38
|
const repo = packageRepositoryUrl(pkg).replace(/^ssh:\/\/git@github.com:/, 'https://github.com/').replace(/^git@github.com:/, 'https://github.com/');
|
|
38
39
|
if (!repo.startsWith('http')) throw new Error('package repository URL must be an HTTPS URL for release assets');
|
|
@@ -43,48 +44,57 @@ export function cacheDirectory() {
|
|
|
43
44
|
return process.env.BAS_VSP_CACHE_DIR || join(process.env.XDG_CACHE_HOME || join(homedir(), '.cache'), 'sap-ai-dev-toolkit');
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
export function cachedBinaryPath(pkg, platform = process.platform, arch = process.arch) {
|
|
47
|
+
export function cachedBinaryPath(pkg, platform = process.platform, arch = process.arch, env = process.env) {
|
|
47
48
|
const target = binaryTarget(platform, arch);
|
|
48
|
-
return join(cacheDirectory(), `${pkg.name}-${pkg.version}-${target.asset}`);
|
|
49
|
+
return join(cacheDirectory(env), `${pkg.name}-${pkg.version}-${target.asset}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function legacyCachedBinaryPath(pkg, platform, arch, env) {
|
|
53
|
+
const target = binaryTarget(platform, arch);
|
|
54
|
+
const configuredCache = brandedEnvValue(env, 'CACHE_DIR');
|
|
55
|
+
const cacheRoot = configuredCache || join(env.XDG_CACHE_HOME || join(homedir(), '.cache'), 'bas-mcp-addon');
|
|
56
|
+
return join(cacheRoot, `bas-mcp-addon-${pkg.version}-${target.asset}`);
|
|
49
57
|
}
|
|
50
58
|
|
|
51
59
|
export async function findBinary(pkg, options = {}) {
|
|
52
60
|
const env = options.env || process.env;
|
|
53
|
-
if (env
|
|
54
|
-
if (!env
|
|
61
|
+
if (brandedEnvValue(env, 'BINARY')) return brandedEnvValue(env, 'BINARY');
|
|
62
|
+
if (!brandedEnvValue(env, 'BINARY_URL')) {
|
|
55
63
|
const bundled = bundledBinaryPath(options.platform, options.arch);
|
|
56
64
|
try { await stat(bundled); return bundled; } catch {}
|
|
57
65
|
}
|
|
58
|
-
const path = cachedBinaryPath(pkg, options.platform, options.arch);
|
|
59
|
-
try { await stat(path); return path; } catch {
|
|
66
|
+
const path = cachedBinaryPath(pkg, options.platform, options.arch, env);
|
|
67
|
+
try { await stat(path); return path; } catch {}
|
|
68
|
+
const legacyPath = legacyCachedBinaryPath(pkg, options.platform, options.arch, env);
|
|
69
|
+
try { await stat(legacyPath); return legacyPath; } catch { return null; }
|
|
60
70
|
}
|
|
61
71
|
|
|
62
72
|
export async function installBinary(pkg, options = {}) {
|
|
63
73
|
const env = options.env || process.env;
|
|
64
|
-
if (env
|
|
65
|
-
if (!env
|
|
74
|
+
if (brandedEnvValue(env, 'BINARY')) return brandedEnvValue(env, 'BINARY');
|
|
75
|
+
if (!brandedEnvValue(env, 'BINARY_URL')) {
|
|
66
76
|
const bundled = bundledBinaryPath(options.platform, options.arch);
|
|
67
77
|
try { await stat(bundled); return bundled; } catch {}
|
|
68
78
|
}
|
|
69
79
|
const target = binaryTarget(options.platform, options.arch);
|
|
70
80
|
const base = releaseBaseUrl(pkg, pkg.version);
|
|
71
|
-
const assetUrl = env
|
|
81
|
+
const assetUrl = brandedEnvValue(env, 'BINARY_URL') || `${base}/${target.asset}`;
|
|
72
82
|
const checksumUrl = `${base}/checksums.txt`;
|
|
73
83
|
const response = await fetch(assetUrl);
|
|
74
|
-
if (!response.ok) throw new Error(`VSP binary download failed (${response.status}); set
|
|
84
|
+
if (!response.ok) throw new Error(`VSP binary download failed (${response.status}); set SAP_AI_DEV_TOOLKIT_BINARY or SAP_AI_DEV_TOOLKIT_BINARY_URL`);
|
|
75
85
|
const bytes = Buffer.from(await response.arrayBuffer());
|
|
76
86
|
const checksumResponse = await fetch(checksumUrl);
|
|
77
|
-
if (!checksumResponse.ok) throw new Error(`VSP checksum download failed (${checksumResponse.status}); set
|
|
87
|
+
if (!checksumResponse.ok) throw new Error(`VSP checksum download failed (${checksumResponse.status}); set SAP_AI_DEV_TOOLKIT_BINARY or SAP_AI_DEV_TOOLKIT_BINARY_URL`);
|
|
78
88
|
const checksumText = await checksumResponse.text();
|
|
79
89
|
const expected = checksumText.split(/\r?\n/).map(line => line.trim().split(/\s+/)).find(parts => parts.length >= 2 && (parts[1] === target.asset || parts[1].endsWith(`/${target.asset}`)))?.[0];
|
|
80
|
-
if (!expected) throw new Error(`checksums.txt has no entry for ${target.asset}; set
|
|
90
|
+
if (!expected) throw new Error(`checksums.txt has no entry for ${target.asset}; set SAP_AI_DEV_TOOLKIT_BINARY or SAP_AI_DEV_TOOLKIT_BINARY_URL`);
|
|
81
91
|
const actual = createHash('sha256').update(bytes).digest('hex');
|
|
82
92
|
if (actual.toLowerCase() !== expected.toLowerCase()) throw new Error(`VSP checksum mismatch for ${target.asset}`);
|
|
83
|
-
const dir = cacheDirectory();
|
|
93
|
+
const dir = cacheDirectory(env);
|
|
84
94
|
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
85
95
|
await chmod(dir, 0o700);
|
|
86
|
-
const destination = cachedBinaryPath(pkg, options.platform, options.arch);
|
|
87
|
-
const temporary = join(tmpdir(), `
|
|
96
|
+
const destination = cachedBinaryPath(pkg, options.platform, options.arch, env);
|
|
97
|
+
const temporary = join(tmpdir(), `sap-ai-dev-toolkit-vsp-${process.pid}-${Date.now()}`);
|
|
88
98
|
await writeFile(temporary, bytes, { mode: 0o700 });
|
|
89
99
|
await chmod(temporary, 0o700);
|
|
90
100
|
await rename(temporary, destination);
|
package/src/branding.mjs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export const PRODUCT_NAME = 'sap-ai-dev-toolkit';
|
|
2
|
+
export const ENV_PREFIX = 'SAP_AI_DEV_TOOLKIT_';
|
|
3
|
+
|
|
4
|
+
const LEGACY_ENV_PREFIX = 'BAS_VSP_';
|
|
5
|
+
|
|
6
|
+
export function brandedEnvValue(env, suffix) {
|
|
7
|
+
const name = suffix.startsWith(ENV_PREFIX) ? suffix.slice(ENV_PREFIX.length) : suffix;
|
|
8
|
+
const currentKey = `${ENV_PREFIX}${name}`;
|
|
9
|
+
const legacyKey = `${LEGACY_ENV_PREFIX}${name}`;
|
|
10
|
+
return env?.[currentKey] !== undefined ? env[currentKey] : env?.[legacyKey];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function withBrandedEnvironment(env = process.env) {
|
|
14
|
+
const result = { ...env };
|
|
15
|
+
for (const [key, value] of Object.entries(env)) {
|
|
16
|
+
if (!key.startsWith(LEGACY_ENV_PREFIX)) continue;
|
|
17
|
+
const currentKey = `${ENV_PREFIX}${key.slice(LEGACY_ENV_PREFIX.length)}`;
|
|
18
|
+
if (result[currentKey] === undefined) result[currentKey] = value;
|
|
19
|
+
}
|
|
20
|
+
return result;
|
|
21
|
+
}
|
package/src/cf-destination.mjs
CHANGED
|
@@ -695,7 +695,7 @@ export async function resolveConfiguredCloudFoundryDestination({ env = process.e
|
|
|
695
695
|
const destinationInstanceGuid = configuredValue(env, 'BAS_CF_DESTINATION_INSTANCE_GUID');
|
|
696
696
|
const destinationInstanceName = configuredValue(env, 'BAS_CF_DESTINATION_INSTANCE');
|
|
697
697
|
const destinationKeyName = configuredValue(env, 'BAS_CF_DESTINATION_KEY');
|
|
698
|
-
const name = configuredValue(env, 'BAS_CF_DESTINATION_NAME') ||
|
|
698
|
+
const name = configuredValue(env, 'BAS_CF_DESTINATION_NAME') || text(brandedEnvValue(env, 'DESTINATION'));
|
|
699
699
|
if (!spaceGuid || !destinationInstanceGuid || !destinationInstanceName || !destinationKeyName || !name) {
|
|
700
700
|
throw new Error('Cloud Foundry destination configuration is incomplete; rerun sap-ai-dev --setup.');
|
|
701
701
|
}
|
package/src/demo-vsp.mjs
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
const objects = new Map([
|
|
2
|
+
['CLAS:ZCL_DEMO_ORDER', `CLASS zcl_demo_order DEFINITION PUBLIC FINAL CREATE PUBLIC.\n PUBLIC SECTION.\n METHODS get_status IMPORTING order_id TYPE string RETURNING VALUE(status) TYPE string.\nENDCLASS.\n\nCLASS zcl_demo_order IMPLEMENTATION.\n METHOD get_status.\n status = |Order { order_id } is ready|.\n ENDMETHOD.\nENDCLASS.`],
|
|
3
|
+
['DDLS:ZI_DEMO_ORDER', `@EndUserText.label: 'Demo Orders'\ndefine view entity ZI_DEMO_ORDER\n as select from zdemo_order\n{\n key order_id as OrderId,\n description as Description\n}`]
|
|
4
|
+
]);
|
|
5
|
+
|
|
6
|
+
const transports = new Map([
|
|
7
|
+
['DEMO000001', { status: 'modifiable', description: 'Offline demo transport', objects: ['ZCL_DEMO_ORDER', 'ZI_DEMO_ORDER'] }]
|
|
8
|
+
]);
|
|
9
|
+
const tools = [
|
|
10
|
+
['GetSource', { object_type: 'string', name: 'string' }, ['object_type', 'name']],
|
|
11
|
+
['WriteSource', { object_type: 'string', name: 'string', source: 'string' }, ['object_type', 'name', 'source']],
|
|
12
|
+
['SearchObject', { query: 'string' }, ['query']],
|
|
13
|
+
['GetSystemInfo', {}, []],
|
|
14
|
+
['GetFeatures', {}, []],
|
|
15
|
+
['GetConnectionInfo', {}, []],
|
|
16
|
+
['GetAPIReleaseState', { object_uri: 'string' }, ['object_uri']],
|
|
17
|
+
['GetTransport', { transport: 'string' }, ['transport']],
|
|
18
|
+
['ListTransports', {}, []],
|
|
19
|
+
['GetTransportInfo', { object_url: 'string', dev_class: 'string' }, ['object_url', 'dev_class']],
|
|
20
|
+
['ListDependencies', { object_uri: 'string' }, ['object_uri']],
|
|
21
|
+
['GetInactiveObjects', {}, []],
|
|
22
|
+
['RunUnitTests', { object_name: 'string' }, ['object_name']],
|
|
23
|
+
['RunATCCheck', { object_name: 'string' }, ['object_name']],
|
|
24
|
+
['SyntaxCheck', { object_type: 'string', name: 'string', source: 'string' }, ['object_type', 'name', 'source']],
|
|
25
|
+
['CreateTransport', { description: 'string' }, ['description']]
|
|
26
|
+
].map(([name, properties, required]) => ({
|
|
27
|
+
name,
|
|
28
|
+
description: `Offline demo fixture for ${name}. No live SAP system is contacted.`,
|
|
29
|
+
inputSchema: {
|
|
30
|
+
type: 'object',
|
|
31
|
+
properties: Object.fromEntries(Object.entries(properties).map(([key, type]) => [key, { type }])),
|
|
32
|
+
required,
|
|
33
|
+
additionalProperties: false
|
|
34
|
+
}
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
function send(id, result) {
|
|
38
|
+
process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function toolResult(value) {
|
|
42
|
+
return { isError: false, content: [{ type: 'text', text: JSON.stringify(value) }], structuredContent: value };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function errorResult(message) {
|
|
46
|
+
return { isError: true, content: [{ type: 'text', text: message }] };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function key(args) {
|
|
50
|
+
return `${String(args.object_type || '').toUpperCase()}:${String(args.name || '').toUpperCase()}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getSource(args) {
|
|
54
|
+
const source = objects.get(key(args));
|
|
55
|
+
if (source === undefined) return errorResult(`Object ${args.object_type} ${args.name} is not in the demo fixture.`);
|
|
56
|
+
return { isError: false, content: [{ type: 'text', text: source }], structuredContent: { source } };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function callTool(name, args = {}) {
|
|
60
|
+
if (name === 'GetSource') return getSource(args);
|
|
61
|
+
if (name === 'WriteSource') {
|
|
62
|
+
objects.set(key(args), args.source);
|
|
63
|
+
return toolResult({ status: 'saved-in-memory', object_type: args.object_type, name: args.name });
|
|
64
|
+
}
|
|
65
|
+
if (name === 'SearchObject') {
|
|
66
|
+
const query = String(args.query || '').toUpperCase();
|
|
67
|
+
return toolResult({ objects: [...objects.keys()].filter(value => value.includes(query)).map(value => ({ name: value.split(':')[1], uri: `/sap/bc/adt/oo/classes/${value.split(':')[1].toLowerCase()}` })) });
|
|
68
|
+
}
|
|
69
|
+
if (name === 'GetSystemInfo') return toolResult({ system_id: 'DEMO', release: 'ABAP Platform 2023', database: 'Offline fixture' });
|
|
70
|
+
if (name === 'GetFeatures') return toolResult({ abap_cloud: true, rap_odata: true, cts_transports: true, offline_demo: true });
|
|
71
|
+
if (name === 'GetConnectionInfo') return toolResult({ destination: 'demo', connection: 'offline fixture', live_sap: false });
|
|
72
|
+
if (name === 'GetAPIReleaseState') {
|
|
73
|
+
return toolResult({ object_uri: args.object_uri, release_state: 'not_released', evidence: 'Synthetic API release-state example only.' });
|
|
74
|
+
}
|
|
75
|
+
if (name === 'GetTransport') return transports.has(args.transport) ? toolResult(transports.get(args.transport)) : errorResult(`Transport ${args.transport} is not in the demo fixture.`);
|
|
76
|
+
if (name === 'ListTransports') return toolResult({ transports: [...transports.entries()].map(([transport, details]) => ({ transport, ...details })) });
|
|
77
|
+
if (name === 'GetTransportInfo') return toolResult({ object_url: args.object_url, dev_class: args.dev_class, eligible_requests: ['DEMO000001'], lock_status: 'unlocked' });
|
|
78
|
+
if (name === 'ListDependencies') return toolResult({ dependencies: [], object_uri: args.object_uri, note: 'No additional dependencies in this fixture.' });
|
|
79
|
+
if (name === 'GetInactiveObjects') return toolResult({ objects: [] });
|
|
80
|
+
if (name === 'RunUnitTests') return toolResult({ object_name: args.object_name, status: 'passed', passed: 3, failed: 0, duration_ms: 18 });
|
|
81
|
+
if (name === 'RunATCCheck') return toolResult({ object_name: args.object_name, status: 'passed', findings: [] });
|
|
82
|
+
if (name === 'SyntaxCheck') return toolResult({ status: 'clean', errors: 0, warnings: 0 });
|
|
83
|
+
if (name === 'CreateTransport') {
|
|
84
|
+
const transport = `DEMO${String(transports.size + 1).padStart(6, '0')}`;
|
|
85
|
+
const result = { status: 'created-in-memory', transport, description: args.description };
|
|
86
|
+
transports.set(transport, result);
|
|
87
|
+
return toolResult(result);
|
|
88
|
+
}
|
|
89
|
+
return errorResult(`Tool ${name} is not implemented by the offline demo.`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let buffer = '';
|
|
93
|
+
process.stdin.setEncoding('utf8');
|
|
94
|
+
process.stdin.on('data', chunk => {
|
|
95
|
+
buffer += chunk;
|
|
96
|
+
let newline;
|
|
97
|
+
while ((newline = buffer.indexOf('\n')) >= 0) {
|
|
98
|
+
const line = buffer.slice(0, newline).trim();
|
|
99
|
+
buffer = buffer.slice(newline + 1);
|
|
100
|
+
if (!line) continue;
|
|
101
|
+
let message;
|
|
102
|
+
try { message = JSON.parse(line); } catch { continue; }
|
|
103
|
+
if (message.method === 'initialize') {
|
|
104
|
+
send(message.id, { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'sap-ai-dev-toolkit-offline-demo', version: '1.0.0' } });
|
|
105
|
+
} else if (message.method === 'tools/list') {
|
|
106
|
+
send(message.id, { tools });
|
|
107
|
+
} else if (message.method === 'tools/call') {
|
|
108
|
+
send(message.id, callTool(message.params?.name, message.params?.arguments));
|
|
109
|
+
} else if (message.method && message.id !== undefined) {
|
|
110
|
+
send(message.id, {});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
});
|