sap-ai-dev-toolkit 0.9.9921 → 0.9.9923

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 (62) hide show
  1. package/.github/agents/sap-btp-infrastructure-admin.agent.md +23 -0
  2. package/.github/agents/sap-bw-datasphere-migration-specialist.agent.md +22 -0
  3. package/.github/agents/sap-datasphere-developer.agent.md +22 -0
  4. package/.github/agents/sap-devops-observability-specialist.agent.md +54 -0
  5. package/.github/agents/sap-process-automation-developer.agent.md +50 -0
  6. package/.github/skills/btp-platform-administration/SKILL.md +53 -0
  7. package/.github/skills/bw-datasphere-migration/SKILL.md +66 -0
  8. package/.github/skills/cloud-alm-operations/SKILL.md +135 -0
  9. package/.github/skills/datasphere-administration/SKILL.md +40 -0
  10. package/.github/skills/datasphere-development/SKILL.md +55 -0
  11. package/.github/skills/process-automation-development/SKILL.md +113 -0
  12. package/NOTICE +2 -1
  13. package/README.md +152 -8
  14. package/dist/checksums.txt +5 -5
  15. package/dist/vsp-darwin-arm64 +0 -0
  16. package/dist/vsp-darwin-x64 +0 -0
  17. package/dist/vsp-linux-arm64 +0 -0
  18. package/dist/vsp-linux-x64 +0 -0
  19. package/dist/vsp-win32-x64.exe +0 -0
  20. package/inventory.md +60 -0
  21. package/package.json +8 -2
  22. package/patches/vsp-namespaced-class-include-edit.patch +134 -0
  23. package/scripts/build-vsp.mjs +3 -1
  24. package/src/btp-cli.mjs +199 -0
  25. package/src/btp-server.mjs +125 -0
  26. package/src/btp-tools.mjs +776 -0
  27. package/src/cloud-alm-client.mjs +44 -0
  28. package/src/cloud-alm-config.mjs +92 -0
  29. package/src/cloud-alm-credentials.mjs +29 -0
  30. package/src/cloud-alm-data.mjs +129 -0
  31. package/src/cloud-alm-server.mjs +115 -0
  32. package/src/cloud-alm-tools.mjs +682 -0
  33. package/src/datasphere-auth.mjs +279 -0
  34. package/src/datasphere-cli.mjs +220 -0
  35. package/src/datasphere-command.mjs +236 -0
  36. package/src/datasphere-config.mjs +154 -0
  37. package/src/datasphere-credentials.mjs +37 -0
  38. package/src/datasphere-csn.mjs +73 -0
  39. package/src/datasphere-discovery.mjs +151 -0
  40. package/src/datasphere-server.mjs +249 -0
  41. package/src/datasphere-tools.mjs +809 -0
  42. package/src/hana-database.mjs +3 -3
  43. package/src/hana-inspector.mjs +2 -2
  44. package/src/harnesses.mjs +31 -1
  45. package/src/install-user-assets.mjs +1 -1
  46. package/src/integration-common.mjs +7 -7
  47. package/src/integration-config.mjs +5 -0
  48. package/src/integration-credentials.mjs +25 -16
  49. package/src/integration-http.mjs +24 -7
  50. package/src/launcher.mjs +56 -4
  51. package/src/mcp-config.mjs +37 -1
  52. package/src/process-automation-automation-tools.mjs +125 -0
  53. package/src/process-automation-client.mjs +36 -0
  54. package/src/process-automation-common.mjs +69 -0
  55. package/src/process-automation-config.mjs +142 -0
  56. package/src/process-automation-credentials.mjs +37 -0
  57. package/src/process-automation-inbox-tools.mjs +246 -0
  58. package/src/process-automation-server.mjs +139 -0
  59. package/src/process-automation-tools.mjs +727 -0
  60. package/src/process-automation-visibility-tools.mjs +182 -0
  61. package/src/setup.mjs +623 -11
  62. package/tools.md +138 -0
package/src/setup.mjs CHANGED
@@ -15,6 +15,19 @@ import { createIntegrationClients, verificationLines, verifyIntegrationClients }
15
15
  import { OPTIONAL_COMPONENT_KEYS, OPTIONAL_INPUTS, parseOptionalConfig, resolveOptionalConfig } from './integration-optional-config.mjs';
16
16
  import { discoverCertificateDnsNames } from './tls-adt-proxy.mjs';
17
17
  import { findRemoteControllableSsoBrowsers } from './sso-browsers.mjs';
18
+ import { parseDatasphereCredentials, resolveDatasphereConfig } from './datasphere-config.mjs';
19
+ import { datasphereCredentialsPath, readStoredDatasphereCredentials, storeDatasphereCredentials } from './datasphere-credentials.mjs';
20
+ import { authorizationCodeLogin, createDatasphereTokenProvider, openInBrowser } from './datasphere-auth.mjs';
21
+ import { PROCESS_AUTOMATION_OAUTH, parseProcessAutomationCredentials, parseProcessAutomationServiceKey, resolveProcessAutomationConfig } from './process-automation-config.mjs';
22
+ import { readStoredProcessAutomationCredentials, storeProcessAutomationCredentials } from './process-automation-credentials.mjs';
23
+ import { createProcessAutomationRestClient } from './process-automation-client.mjs';
24
+ import { createBtpCli, locateBtpCli, loginBtpCli, readBtpSettings, storeBtpSettings } from './btp-cli.mjs';
25
+ import { parseCloudAlmCredentials, parseCloudAlmServiceKey, resolveCloudAlmConfig } from './cloud-alm-config.mjs';
26
+ import { readStoredCloudAlmCredentials, storeCloudAlmCredentials } from './cloud-alm-credentials.mjs';
27
+ import { createCloudAlmRestClient, verifyCloudAlmAccess } from './cloud-alm-client.mjs';
28
+ import { createStaticServiceClient } from './integration-http.mjs';
29
+ import { connectHana } from './hana-database.mjs';
30
+ import { diagnosticText } from './redact.mjs';
18
31
 
19
32
  const SETUP_COMMAND = 'sap-ai-dev --setup';
20
33
 
@@ -314,18 +327,21 @@ function defaultVerifyIntegration(config, { env }) {
314
327
  return verifyIntegrationClients(createIntegrationClients(config, { env }));
315
328
  }
316
329
 
317
- /** Read a pasted service key: JSON text (line breaks from a multi-line paste are dropped) or a path to a .json file. */
318
- function pastedServiceKey(side, value, env) {
330
+ /** Read pasted JSON text (line breaks from a multi-line paste are dropped) or the contents of a .json file path. */
331
+ function readPastedJson(value, env) {
319
332
  const trimmed = String(value || '').trim();
320
- let text = trimmed;
321
- if (!trimmed.startsWith('{')) {
322
- const path = trimmed.replace(/^~(?=$|[\\/])/, env.HOME || env.USERPROFILE || '~');
323
- try {
324
- text = readFileSync(path, 'utf8');
325
- } catch {
326
- throw new Error('Paste the service key JSON (starting with {) or the path to a readable .json file.');
327
- }
333
+ if (trimmed.startsWith('{')) return trimmed;
334
+ const path = trimmed.replace(/^~(?=$|[\\/])/, env.HOME || env.USERPROFILE || '~');
335
+ try {
336
+ return readFileSync(path, 'utf8');
337
+ } catch {
338
+ throw new Error('Paste the service key JSON (starting with {) or the path to a readable .json file.');
328
339
  }
340
+ }
341
+
342
+ /** Read a pasted service key: JSON text or a path to a .json file. */
343
+ function pastedServiceKey(side, value, env) {
344
+ const text = readPastedJson(value, env);
329
345
  return OPTIONAL_COMPONENT_KEYS.includes(side.toLowerCase())
330
346
  ? parseOptionalConfig(side.toLowerCase(), text, { source: 'setup', allowRawCtms: true })
331
347
  : parseIntegrationServiceKey(side, text);
@@ -608,6 +624,586 @@ export async function runIntegrationSuiteSetup({
608
624
  return { selected: [], ...(result || {}) };
609
625
  }
610
626
 
627
+ export function datasphereCredentialStatus(env = process.env) {
628
+ try {
629
+ const config = resolveDatasphereConfig(env, { stored: readStoredDatasphereCredentials(env) });
630
+ const lines = [`Datasphere tenant ${new URL(config.url).host} (${config.auth}, source: ${config.source})`];
631
+ if (config.sql) lines.push(`SQL user ${config.sql.user}@${config.sql.host}`);
632
+ return { found: true, source: config.source, auth: config.auth, config, lines };
633
+ } catch (error) {
634
+ return { found: false, source: null, auth: null, config: null, lines: [String(error?.message || '')] };
635
+ }
636
+ }
637
+
638
+ function datasphereRestClient(config, { env, accessToken } = {}) {
639
+ const shared = {
640
+ label: 'DATASPHERE',
641
+ url: config.url,
642
+ csrfPath: '/api/v1/datasphere/consumption/catalog/spaces',
643
+ env
644
+ };
645
+ if (accessToken) {
646
+ return createStaticServiceClient({ ...shared, authorization: async () => `Bearer ${accessToken}` });
647
+ }
648
+ const provider = createDatasphereTokenProvider({ config, env });
649
+ return createStaticServiceClient({ ...shared, authorization: async () => `Bearer ${(await provider.token()).accessToken}` });
650
+ }
651
+
652
+ async function defaultVerifyDatasphere(config, { env, accessToken } = {}) {
653
+ try {
654
+ const payload = await datasphereRestClient(config, { env, accessToken }).request('GET', '/api/v1/datasphere/consumption/catalog/spaces');
655
+ return { ok: true, message: `signed in; ${(payload?.value ?? []).length} spaces visible` };
656
+ } catch (error) {
657
+ return { ok: false, message: diagnosticText(error) };
658
+ }
659
+ }
660
+
661
+ /** Prompt for a Datasphere OAuth client, verify it online, optionally add SQL access, and store it. */
662
+ export async function promptDatasphere({
663
+ env = process.env,
664
+ input = stdin,
665
+ output = stdout,
666
+ verifyDatasphere = defaultVerifyDatasphere,
667
+ login = authorizationCodeLogin
668
+ } = {}) {
669
+ print(output, '');
670
+ print(output, formatStatus('SAP Datasphere tools', 'step', output, 'Datasphere'));
671
+ print(output, ' Connects your AI agent to SAP Datasphere through an OAuth client from System > App Integration. Credentials are checked online and stored only for you, never in mcp.json.');
672
+ const status = datasphereCredentialStatus(env);
673
+ for (const line of status.lines) print(output, ` ${line}`);
674
+ if (status.found) {
675
+ if (status.source === 'environment') return true;
676
+ if (!await confirmPrompt('Replace the stored Datasphere sign-in? (y/n, Enter=no)', { input, output, defaultYes: false })) return true;
677
+ }
678
+
679
+ const auth = await selectPrompt({
680
+ message: 'How should the toolkit sign in to SAP Datasphere?',
681
+ defaultValue: 'technical',
682
+ choices: [
683
+ { name: 'Technical user (Technical User OAuth client): objects, tasks, Data Marketplace, connections, certificates, consumption data', value: 'technical', shortcut: 't' },
684
+ { name: 'Your user (Interactive Usage OAuth client, redirect URI http://localhost:8080): everything, including spaces, users, roles, database users and workload', value: 'interactive', shortcut: 'y' }
685
+ ]
686
+ }, { input, output });
687
+
688
+ const FIELD_LABELS = { url: 'DATASPHERE_URL', tokenUrl: 'DATASPHERE_TOKEN_URL', authorizationUrl: 'DATASPHERE_AUTHORIZATION_URL' };
689
+ const validateUrl = field => value => {
690
+ try {
691
+ parseDatasphereCredentials({
692
+ auth,
693
+ url: field === 'url' ? value : 'https://placeholder.invalid',
694
+ tokenUrl: field === 'tokenUrl' ? value : 'https://placeholder.invalid/oauth/token',
695
+ clientId: 'placeholder',
696
+ clientSecret: 'placeholder',
697
+ ...(auth === 'interactive'
698
+ ? { authorizationUrl: field === 'authorizationUrl' ? value : 'https://placeholder.invalid', refreshToken: 'placeholder' }
699
+ : {})
700
+ }, 'environment');
701
+ return true;
702
+ } catch (error) {
703
+ return String(error?.message || '').startsWith(FIELD_LABELS[field]) ? error.message : true;
704
+ }
705
+ };
706
+ const tenantUrl = await textPrompt({
707
+ message: 'Datasphere tenant URL (for example https://<tenant>.eu10.hcs.cloud.sap)',
708
+ validate: validateUrl('url')
709
+ }, { input, output });
710
+ const tokenUrl = await textPrompt({
711
+ message: 'Token URL',
712
+ validate: validateUrl('tokenUrl')
713
+ }, { input, output });
714
+ const clientId = await textPrompt({ message: 'OAuth client ID', secret: true }, { input, output });
715
+ const clientSecret = await textPrompt({ message: 'OAuth client secret', secret: true }, { input, output });
716
+ let authorizationUrl = '';
717
+ if (auth === 'interactive') {
718
+ authorizationUrl = await textPrompt({
719
+ message: 'Authorization URL',
720
+ validate: validateUrl('authorizationUrl')
721
+ }, { input, output });
722
+ }
723
+
724
+ let parsed;
725
+ try {
726
+ parsed = resolveDatasphereConfig({
727
+ ...env,
728
+ DATASPHERE_URL: tenantUrl,
729
+ DATASPHERE_TOKEN_URL: tokenUrl,
730
+ DATASPHERE_CLIENT_ID: clientId,
731
+ DATASPHERE_CLIENT_SECRET: clientSecret,
732
+ DATASPHERE_AUTH: auth,
733
+ // The refresh token only exists after the sign-in below; a placeholder
734
+ // satisfies the interactive validation and is replaced by the login result.
735
+ ...(auth === 'interactive' ? { DATASPHERE_AUTHORIZATION_URL: authorizationUrl, DATASPHERE_REFRESH_TOKEN: 'pending-sign-in' } : {})
736
+ });
737
+ } catch (error) {
738
+ print(output, formatStatus(error.message, 'warning', output, 'Datasphere'));
739
+ return false;
740
+ }
741
+
742
+ let refreshToken = null;
743
+ let accessToken = null;
744
+ if (auth === 'interactive') {
745
+ const mode = await selectPrompt({
746
+ message: 'How should the sign-in finish?',
747
+ defaultValue: 'browser',
748
+ choices: [
749
+ { name: 'Open the browser here (local machine)', value: 'browser', shortcut: 'b' },
750
+ { name: 'Paste the redirected URL (BAS or remote machine)', value: 'paste', shortcut: 'p' }
751
+ ]
752
+ }, { input, output });
753
+ try {
754
+ const session = await login({
755
+ config: parsed,
756
+ mode,
757
+ openUrl: url => {
758
+ print(output, formatStatus(`Sign in with this URL (it also opened in your browser): ${url}`, 'info', output, 'Datasphere'));
759
+ openInBrowser(url);
760
+ },
761
+ promptPaste: () => textPrompt({
762
+ message: 'Paste the full URL the browser was redirected to (or just the code)',
763
+ secret: true
764
+ }, { input, output })
765
+ });
766
+ refreshToken = session.refreshToken || null;
767
+ accessToken = session.accessToken || null;
768
+ } catch (error) {
769
+ print(output, formatStatus(`Datasphere sign-in failed: ${diagnosticText(error)}`, 'warning', output, 'Datasphere'));
770
+ return false;
771
+ }
772
+ }
773
+
774
+ const verification = await verifyDatasphere(parsed, { env, ...(accessToken ? { accessToken } : {}) });
775
+ if (!verification.ok) {
776
+ print(output, formatStatus(`Verification failed: ${verification.message}`, 'warning', output, 'Datasphere'));
777
+ if (!await confirmPrompt('Save these credentials anyway? (y/n, Enter=no)', { input, output, defaultYes: false })) return false;
778
+ } else {
779
+ print(output, formatStatus(`Datasphere ${verification.message}.`, 'success', output, 'Datasphere'));
780
+ }
781
+
782
+ let sql = null;
783
+ if (await confirmPrompt('Add SQL access with a Datasphere database user (Open SQL schema)? (y/n, Enter=no)', { input, output, defaultYes: false })) {
784
+ const sqlHost = await textPrompt({ message: 'Database user host name' }, { input, output });
785
+ const sqlPort = await textPrompt({ message: 'Port (Enter=443)', placeholder: '443', required: false }, { input, output });
786
+ const sqlUser = await textPrompt({ message: 'Database user name' }, { input, output });
787
+ const sqlPassword = await textPrompt({ message: 'Database user password', secret: true }, { input, output });
788
+ const sqlSchema = await textPrompt({
789
+ message: `Schema (Enter=${sqlUser})`,
790
+ placeholder: sqlUser,
791
+ required: false
792
+ }, { input, output });
793
+ const port = Number(sqlPort || 443);
794
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
795
+ print(output, formatStatus('DATASPHERE_SQL_PORT must be an integer between 1 and 65535', 'warning', output, 'Datasphere'));
796
+ return false;
797
+ }
798
+ sql = { host: sqlHost, port, user: sqlUser, password: sqlPassword, schema: sqlSchema || sqlUser };
799
+ try {
800
+ const database = await connectHana(sql, { queryTimeoutMs: 15_000 });
801
+ try {
802
+ await database.query('SELECT CURRENT_USER FROM DUMMY');
803
+ } finally {
804
+ await database.close();
805
+ }
806
+ print(output, formatStatus(`SQL access verified (${sqlUser}@${sqlHost}).`, 'success', output, 'Datasphere'));
807
+ } catch (error) {
808
+ print(output, formatStatus(`SQL verification failed: ${diagnosticText(error)}`, 'warning', output, 'Datasphere'));
809
+ if (!await confirmPrompt('Save these credentials anyway? (y/n, Enter=no)', { input, output, defaultYes: false })) return false;
810
+ }
811
+ }
812
+
813
+ const record = {
814
+ auth: parsed.auth,
815
+ url: parsed.url,
816
+ tokenurl: parsed.tokenUrl,
817
+ clientid: parsed.clientId,
818
+ clientsecret: parsed.clientSecret,
819
+ ...(parsed.auth === 'interactive' ? { authorizationurl: parsed.authorizationUrl, ...(refreshToken ? { refreshtoken: refreshToken } : {}) } : {}),
820
+ ...(sql ? { sql } : {})
821
+ };
822
+ const path = await storeDatasphereCredentials(record, env);
823
+ print(output, formatStatus(`Saved to ${path}`, 'success', output, 'Datasphere'));
824
+ return true;
825
+ }
826
+
827
+ /** `sap-ai-dev --setup-datasphere`: only the Datasphere step (sign in, verify, add the server). */
828
+ export async function runDatasphereSetup({
829
+ env = process.env,
830
+ input = stdin,
831
+ output = stdout,
832
+ install = installMcpConfig,
833
+ verifyDatasphere = defaultVerifyDatasphere,
834
+ login = authorizationCodeLogin
835
+ } = {}) {
836
+ if (!input.isTTY || !output.isTTY) {
837
+ print(output, formatStatus('Interactive setup needs a terminal. Rerun sap-ai-dev --setup-datasphere from a terminal.', 'warning', output, 'SAP AI Dev Toolkit'));
838
+ return { skipped: true, reason: 'non-tty' };
839
+ }
840
+ const ok = await promptDatasphere({ env, input, output, verifyDatasphere, login });
841
+ if (!ok) {
842
+ print(output, formatStatus('Datasphere setup cancelled; no MCP entry was added.', 'warning', output, 'SAP AI Dev Toolkit'));
843
+ return { skipped: true, reason: 'no-credentials' };
844
+ }
845
+ const result = await install([], { env, sapDevelopmentServers: ['datasphere'], additive: true });
846
+ print(output, '');
847
+ print(output, formatStatus(`Configured the SAP Datasphere MCP server${result?.path ? ` in ${result.path}` : ''}.`, 'success', output, 'SAP AI Dev Toolkit'));
848
+ for (const message of Array.isArray(result?.warnings) ? result.warnings : []) print(output, formatStatus(message, 'warning', output, 'Warning'));
849
+ return { selected: [], ...(result || {}) };
850
+ }
851
+
852
+ export function processAutomationCredentialStatus(env = process.env) {
853
+ try {
854
+ const config = resolveProcessAutomationConfig(env, { stored: readStoredProcessAutomationCredentials(env) });
855
+ const lines = [`SAP Build Process Automation API ${new URL(config.url).host} (${config.auth}, source: ${config.source})`];
856
+ return { found: true, source: config.source, auth: config.auth, config, lines };
857
+ } catch (error) {
858
+ return { found: false, source: null, auth: null, config: null, lines: [String(error?.message || '')] };
859
+ }
860
+ }
861
+
862
+ async function defaultVerifyProcessAutomation(config, { env, accessToken } = {}) {
863
+ try {
864
+ await createProcessAutomationRestClient(config, { env, ...(accessToken ? { accessToken } : {}) })
865
+ .request('GET', '/public/workflow/rest/v1/workflow-definitions', { query: { $top: 1 } });
866
+ return { ok: true, message: 'signed in; Workflow API reachable' };
867
+ } catch (error) {
868
+ return { ok: false, message: diagnosticText(error) };
869
+ }
870
+ }
871
+
872
+ /** Prompt for a Process Automation service key, optionally sign in as the user, verify it online, and store it. */
873
+ export async function promptProcessAutomation({
874
+ env = process.env,
875
+ input = stdin,
876
+ output = stdout,
877
+ verifyProcessAutomation = defaultVerifyProcessAutomation,
878
+ login = authorizationCodeLogin
879
+ } = {}) {
880
+ const label = 'Process Automation';
881
+ print(output, '');
882
+ print(output, formatStatus('SAP Build Process Automation tools', 'step', output, label));
883
+ print(output, ' Connects your AI agent to SAP Build Process Automation through a service key of a process-automation-service instance. Credentials are checked online and stored only for you, never in mcp.json.');
884
+ const status = processAutomationCredentialStatus(env);
885
+ for (const line of status.lines) print(output, ` ${line}`);
886
+ if (status.found) {
887
+ if (status.source === 'environment') return true;
888
+ if (!await confirmPrompt('Replace the stored SAP Build Process Automation sign-in? (y/n, Enter=no)', { input, output, defaultYes: false })) return true;
889
+ }
890
+
891
+ const auth = await selectPrompt({
892
+ message: 'How should the toolkit sign in to SAP Build Process Automation?',
893
+ defaultValue: 'technical',
894
+ choices: [
895
+ { name: 'Technical client (service key client credentials): processes and task decisions run as the service key\'s technical user', value: 'technical', shortcut: 't' },
896
+ { name: 'Your user (sign in with the service key client): actions are attributed to you; needs ProcessAutomationAdmin or ProcessAutomationParticipant', value: 'interactive', shortcut: 'y' }
897
+ ]
898
+ }, { input, output });
899
+
900
+ const pasted = await textPrompt({
901
+ message: 'Paste the Process Automation service key JSON or the path to its .json file',
902
+ secret: true,
903
+ validate: value => {
904
+ try {
905
+ parseProcessAutomationServiceKey(readPastedJson(value, env));
906
+ return true;
907
+ } catch (error) {
908
+ return String(error?.message || 'invalid service key');
909
+ }
910
+ }
911
+ }, { input, output });
912
+
913
+ const interactive = auth === 'interactive';
914
+ let parsed;
915
+ try {
916
+ parsed = parseProcessAutomationCredentials({
917
+ auth,
918
+ ...parseProcessAutomationServiceKey(readPastedJson(pasted, env)),
919
+ // The refresh token only exists after the sign-in below; a placeholder
920
+ // satisfies the interactive validation and is replaced by the login result.
921
+ ...(interactive ? { refreshToken: 'pending-sign-in' } : {})
922
+ }, 'stored-key');
923
+ } catch (error) {
924
+ print(output, formatStatus(error.message, 'warning', output, label));
925
+ return false;
926
+ }
927
+
928
+ let refreshToken = null;
929
+ let accessToken = null;
930
+ if (interactive) {
931
+ const mode = await selectPrompt({
932
+ message: 'How should the sign-in finish?',
933
+ defaultValue: 'paste',
934
+ choices: [
935
+ { name: 'Paste the redirected URL (works with the service key\'s registered redirect)', value: 'paste', shortcut: 'p' },
936
+ { name: 'Open the browser here with redirect to http://localhost:8080', value: 'browser', shortcut: 'b' }
937
+ ]
938
+ }, { input, output });
939
+ try {
940
+ const session = await login({
941
+ config: parsed,
942
+ mode,
943
+ profile: PROCESS_AUTOMATION_OAUTH,
944
+ openUrl: url => {
945
+ print(output, formatStatus(`Sign in with this URL (it also opened in your browser): ${url}`, 'info', output, label));
946
+ openInBrowser(url);
947
+ },
948
+ promptPaste: () => textPrompt({
949
+ message: 'Paste the full URL the browser was redirected to (or just the code)',
950
+ secret: true
951
+ }, { input, output })
952
+ });
953
+ refreshToken = session.refreshToken || null;
954
+ accessToken = session.accessToken || null;
955
+ } catch (error) {
956
+ print(output, formatStatus(`SAP Build Process Automation sign-in failed: ${diagnosticText(error)}. Choose the technical client if the service key does not allow user sign-in.`, 'warning', output, label));
957
+ return false;
958
+ }
959
+ }
960
+
961
+ const verification = await verifyProcessAutomation(parsed, { env, ...(accessToken ? { accessToken } : {}) });
962
+ if (!verification.ok) {
963
+ print(output, formatStatus(`Verification failed: ${verification.message}`, 'warning', output, label));
964
+ if (!await confirmPrompt('Save these credentials anyway? (y/n, Enter=no)', { input, output, defaultYes: false })) return false;
965
+ } else {
966
+ print(output, formatStatus(`SAP Build Process Automation ${verification.message}.`, 'success', output, label));
967
+ }
968
+
969
+ print(output, ' Automation triggers, automation jobs and BAM/job exports need an environment API key (Control Tower > Environments > API Keys, scopes such as TRIGGER_READ/TRIGGER_EXECUTE). Workflow, inbox, decision and visibility tools work without one.');
970
+ const apiKey = await textPrompt({
971
+ message: 'Environment API key (Enter=skip)',
972
+ secret: true,
973
+ required: false,
974
+ validate: value => (!value || (value.length <= 512 && !/[^\x21-\x7e]/.test(value)) ? true : 'The API key must be a single printable token')
975
+ }, { input, output });
976
+ const environmentId = await textPrompt({
977
+ message: 'Default environment id for API triggers and process starts (Enter=skip)',
978
+ required: false,
979
+ validate: value => (!value || /^[A-Za-z0-9_.-]{1,128}$/.test(value) ? true : 'Use 1-128 letters, digits, dots, dashes or underscores')
980
+ }, { input, output });
981
+
982
+ const record = {
983
+ auth: parsed.auth,
984
+ url: parsed.url,
985
+ tokenurl: parsed.tokenUrl,
986
+ clientid: parsed.clientId,
987
+ clientsecret: parsed.clientSecret,
988
+ ...(interactive && refreshToken ? { refreshtoken: refreshToken } : {}),
989
+ ...(apiKey?.trim() ? { apikey: apiKey.trim() } : {}),
990
+ ...(environmentId?.trim() ? { environmentid: environmentId.trim() } : {})
991
+ };
992
+ const path = await storeProcessAutomationCredentials(record, env);
993
+ print(output, formatStatus(`Saved to ${path}`, 'success', output, label));
994
+ return true;
995
+ }
996
+
997
+ /** `sap-ai-dev --setup-process-automation`: only the Process Automation step (service key, sign in, verify, add the server). */
998
+ export async function runProcessAutomationSetup({
999
+ env = process.env,
1000
+ input = stdin,
1001
+ output = stdout,
1002
+ install = installMcpConfig,
1003
+ verifyProcessAutomation = defaultVerifyProcessAutomation,
1004
+ login = authorizationCodeLogin
1005
+ } = {}) {
1006
+ if (!input.isTTY || !output.isTTY) {
1007
+ print(output, formatStatus('Interactive setup needs a terminal. Rerun sap-ai-dev --setup-process-automation from a terminal.', 'warning', output, 'SAP AI Dev Toolkit'));
1008
+ return { skipped: true, reason: 'non-tty' };
1009
+ }
1010
+ const ok = await promptProcessAutomation({ env, input, output, verifyProcessAutomation, login });
1011
+ if (!ok) {
1012
+ print(output, formatStatus('SAP Build Process Automation setup cancelled; no MCP entry was added.', 'warning', output, 'SAP AI Dev Toolkit'));
1013
+ return { skipped: true, reason: 'no-credentials' };
1014
+ }
1015
+ const result = await install([], { env, sapDevelopmentServers: ['process-automation'], additive: true });
1016
+ print(output, '');
1017
+ print(output, formatStatus(`Configured the SAP Build Process Automation MCP server${result?.path ? ` in ${result.path}` : ''}.`, 'success', output, 'SAP AI Dev Toolkit'));
1018
+ for (const message of Array.isArray(result?.warnings) ? result.warnings : []) print(output, formatStatus(message, 'warning', output, 'Warning'));
1019
+ return { selected: [], ...(result || {}) };
1020
+ }
1021
+
1022
+ async function defaultVerifyBtp({ cli, config, env }) {
1023
+ try {
1024
+ const runtime = createBtpCli({ cli, config, env });
1025
+ const version = await runtime.version();
1026
+ const account = await runtime.json(['get', 'accounts/global-account']);
1027
+ return { ok: true, message: `btp CLI${version ? ` ${version}` : ''} signed in to global account ${account?.displayName} (${account?.subdomain})` };
1028
+ } catch (error) {
1029
+ return { ok: false, loginRequired: error?.code === 'BTP_LOGIN_REQUIRED', message: diagnosticText(error) };
1030
+ }
1031
+ }
1032
+
1033
+ /** Locate the btp CLI, check (or start) its btp login --sso session, and remember the CLI location. */
1034
+ export async function promptBtp({
1035
+ env = process.env,
1036
+ input = stdin,
1037
+ output = stdout,
1038
+ verifyBtp = defaultVerifyBtp,
1039
+ loginBtp = loginBtpCli,
1040
+ locateBtp = locateBtpCli
1041
+ } = {}) {
1042
+ const label = 'SAP BTP';
1043
+ print(output, '');
1044
+ print(output, formatStatus('SAP BTP platform administration tools', 'step', output, label));
1045
+ print(output, ' Connects your AI agent to SAP BTP through your own btp CLI login (btp login --sso). The toolkit stores no credentials; it only remembers where the btp CLI is installed.');
1046
+ let saved;
1047
+ try {
1048
+ saved = readBtpSettings(env).cli;
1049
+ } catch {
1050
+ saved = undefined;
1051
+ }
1052
+ const cli = String(env.BTP_CLI || '').trim() || await locateBtp(env) || (typeof saved === 'string' ? saved.trim() : '');
1053
+ if (!cli) {
1054
+ print(output, formatStatus('The btp CLI was not found on PATH. Install the SAP BTP command line interface from https://tools.hana.ondemand.com/#cloud (or set BTP_CLI to its path), then rerun sap-ai-dev --setup-btp.', 'warning', output, label));
1055
+ return false;
1056
+ }
1057
+ const config = String(env.BTP_CLIENTCONFIG || '').trim() || null;
1058
+ print(output, ` btp CLI: ${cli}${config ? ` (config ${config})` : ''}`);
1059
+
1060
+ let verification = await verifyBtp({ cli, config, env });
1061
+ if (!verification.ok && verification.loginRequired
1062
+ && await confirmPrompt('Sign in now with btp login --sso? (y/n, Enter=yes)', { input, output, defaultYes: true })) {
1063
+ try {
1064
+ await loginBtp({ cli, config, env });
1065
+ } catch (error) {
1066
+ print(output, formatStatus(`btp login failed: ${diagnosticText(error)}`, 'warning', output, label));
1067
+ }
1068
+ verification = await verifyBtp({ cli, config, env });
1069
+ }
1070
+ if (!verification.ok) {
1071
+ print(output, formatStatus(`Verification failed: ${verification.message}`, 'warning', output, label));
1072
+ if (!await confirmPrompt('Add the SAP BTP MCP server anyway? (y/n, Enter=no)', { input, output, defaultYes: false })) return false;
1073
+ } else {
1074
+ print(output, formatStatus(`SAP BTP ${verification.message}.`, 'success', output, label));
1075
+ }
1076
+
1077
+ const path = await storeBtpSettings({ cli, ...(config ? { config } : {}) }, env);
1078
+ print(output, formatStatus(`Saved the btp CLI location to ${path}`, 'success', output, label));
1079
+ return true;
1080
+ }
1081
+
1082
+ /** `sap-ai-dev --setup-btp`: only the SAP BTP step (locate the btp CLI, check the session, add the server). */
1083
+ export async function runBtpSetup({
1084
+ env = process.env,
1085
+ input = stdin,
1086
+ output = stdout,
1087
+ install = installMcpConfig,
1088
+ verifyBtp = defaultVerifyBtp,
1089
+ loginBtp = loginBtpCli,
1090
+ locateBtp = locateBtpCli
1091
+ } = {}) {
1092
+ if (!input.isTTY || !output.isTTY) {
1093
+ print(output, formatStatus('Interactive setup needs a terminal. Rerun sap-ai-dev --setup-btp from a terminal.', 'warning', output, 'SAP AI Dev Toolkit'));
1094
+ return { skipped: true, reason: 'non-tty' };
1095
+ }
1096
+ const ok = await promptBtp({ env, input, output, verifyBtp, loginBtp, locateBtp });
1097
+ if (!ok) {
1098
+ print(output, formatStatus('SAP BTP setup cancelled; no MCP entry was added.', 'warning', output, 'SAP AI Dev Toolkit'));
1099
+ return { skipped: true, reason: 'not-configured' };
1100
+ }
1101
+ const result = await install([], { env, sapDevelopmentServers: ['sap-btp'], additive: true });
1102
+ print(output, '');
1103
+ print(output, formatStatus(`Configured the SAP BTP MCP server${result?.path ? ` in ${result.path}` : ''}.`, 'success', output, 'SAP AI Dev Toolkit'));
1104
+ for (const message of Array.isArray(result?.warnings) ? result.warnings : []) print(output, formatStatus(message, 'warning', output, 'Warning'));
1105
+ return { selected: [], ...(result || {}) };
1106
+ }
1107
+
1108
+ export function cloudAlmCredentialStatus(env = process.env) {
1109
+ try {
1110
+ const config = resolveCloudAlmConfig(env, { stored: readStoredCloudAlmCredentials(env) });
1111
+ const lines = [`SAP Cloud ALM API ${new URL(config.url).host} (source: ${config.source})`];
1112
+ return { found: true, source: config.source, config, lines };
1113
+ } catch (error) {
1114
+ return { found: false, source: null, config: null, lines: [String(error?.message || '')] };
1115
+ }
1116
+ }
1117
+
1118
+ async function defaultVerifyCloudAlm(config, { env } = {}) {
1119
+ const report = await verifyCloudAlmAccess(createCloudAlmRestClient(config, { env }));
1120
+ const failed = Object.entries(report).filter(([, result]) => String(result).startsWith('failed'));
1121
+ if (failed.length) return { ok: false, message: diagnosticText(failed.map(([name, result]) => `${name} ${result}`).join('; ')) };
1122
+ return { ok: true, message: 'Landscape and Analytics APIs reachable' };
1123
+ }
1124
+
1125
+ /** Prompt for a Cloud ALM API service key, verify it online, and store it. */
1126
+ export async function promptCloudAlm({
1127
+ env = process.env,
1128
+ input = stdin,
1129
+ output = stdout,
1130
+ verifyCloudAlm = defaultVerifyCloudAlm
1131
+ } = {}) {
1132
+ const label = 'Cloud ALM';
1133
+ print(output, '');
1134
+ print(output, formatStatus('SAP Cloud ALM tools', 'step', output, label));
1135
+ print(output, ' Connects your AI agent to SAP Cloud ALM for operations through a service key of the "SAP Cloud ALM API" instance (plan standard) in your Cloud ALM subaccount. Credentials are checked online and stored only for you, never in mcp.json.');
1136
+ print(output, ' Grant the instance read scopes such as calm-api.landscape.read, calm-api.analytics.read, calm-api.logs.read, calm-api.exm.read, calm-api.integration-monitoring.read, calm-api.hm.read, calm-api.iep.read and calm-api.features.read; closing alerts also needs calm-api.event-situations.write.');
1137
+ const status = cloudAlmCredentialStatus(env);
1138
+ for (const line of status.lines) print(output, ` ${line}`);
1139
+ if (status.found) {
1140
+ if (status.source === 'environment') return true;
1141
+ if (!await confirmPrompt('Replace the stored SAP Cloud ALM service key? (y/n, Enter=no)', { input, output, defaultYes: false })) return true;
1142
+ }
1143
+
1144
+ const pasted = await textPrompt({
1145
+ message: 'Paste the SAP Cloud ALM API service key JSON or the path to its .json file',
1146
+ secret: true,
1147
+ validate: value => {
1148
+ try {
1149
+ parseCloudAlmServiceKey(readPastedJson(value, env));
1150
+ return true;
1151
+ } catch (error) {
1152
+ return String(error?.message || 'invalid service key');
1153
+ }
1154
+ }
1155
+ }, { input, output });
1156
+
1157
+ let parsed;
1158
+ try {
1159
+ parsed = parseCloudAlmCredentials(parseCloudAlmServiceKey(readPastedJson(pasted, env)), 'stored-key');
1160
+ } catch (error) {
1161
+ print(output, formatStatus(error.message, 'warning', output, label));
1162
+ return false;
1163
+ }
1164
+
1165
+ const verification = await verifyCloudAlm(parsed, { env });
1166
+ if (!verification.ok) {
1167
+ print(output, formatStatus(`Verification failed: ${verification.message}`, 'warning', output, label));
1168
+ if (!await confirmPrompt('Save these credentials anyway? (y/n, Enter=no)', { input, output, defaultYes: false })) return false;
1169
+ } else {
1170
+ print(output, formatStatus(`SAP Cloud ALM ${verification.message}.`, 'success', output, label));
1171
+ }
1172
+
1173
+ const path = await storeCloudAlmCredentials({
1174
+ url: parsed.url,
1175
+ tokenurl: parsed.tokenUrl,
1176
+ clientid: parsed.clientId,
1177
+ clientsecret: parsed.clientSecret
1178
+ }, env);
1179
+ print(output, formatStatus(`Saved to ${path}`, 'success', output, label));
1180
+ return true;
1181
+ }
1182
+
1183
+ /** `sap-ai-dev --setup-cloud-alm`: only the Cloud ALM step (service key, verify, add the server). */
1184
+ export async function runCloudAlmSetup({
1185
+ env = process.env,
1186
+ input = stdin,
1187
+ output = stdout,
1188
+ install = installMcpConfig,
1189
+ verifyCloudAlm = defaultVerifyCloudAlm
1190
+ } = {}) {
1191
+ if (!input.isTTY || !output.isTTY) {
1192
+ print(output, formatStatus('Interactive setup needs a terminal. Rerun sap-ai-dev --setup-cloud-alm from a terminal.', 'warning', output, 'SAP AI Dev Toolkit'));
1193
+ return { skipped: true, reason: 'non-tty' };
1194
+ }
1195
+ const ok = await promptCloudAlm({ env, input, output, verifyCloudAlm });
1196
+ if (!ok) {
1197
+ print(output, formatStatus('SAP Cloud ALM setup cancelled; no MCP entry was added.', 'warning', output, 'SAP AI Dev Toolkit'));
1198
+ return { skipped: true, reason: 'no-credentials' };
1199
+ }
1200
+ const result = await install([], { env, sapDevelopmentServers: ['cloud-alm'], additive: true });
1201
+ print(output, '');
1202
+ print(output, formatStatus(`Configured the SAP Cloud ALM MCP server${result?.path ? ` in ${result.path}` : ''}.`, 'success', output, 'SAP AI Dev Toolkit'));
1203
+ for (const message of Array.isArray(result?.warnings) ? result.warnings : []) print(output, formatStatus(message, 'warning', output, 'Warning'));
1204
+ return { selected: [], ...(result || {}) };
1205
+ }
1206
+
611
1207
  function buildLocalClientDestination(destination, client, authMode, { multipleClients = false, duplicateSystemName = false, browserPath } = {}) {
612
1208
  const name = localClientDestinationName(destination, client, { multipleClients, duplicateSystemName });
613
1209
  const localDestination = { ...destination, name, serverName: name, client };
@@ -1121,9 +1717,13 @@ export async function runSetup({
1121
1717
  sapDevelopmentServers = ['integration-suite'];
1122
1718
  } else if (includeSapDevelopmentToolsPrompt) {
1123
1719
  print(output, '');
1124
- print(output, formatStatus('Optionally add companion MCP servers for full-stack SAP development, HANA inspection, and Integration Suite (CPI/API Management/Developer Hub).', 'step', output, 'Tools'));
1720
+ print(output, formatStatus('Optionally add companion MCP servers for full-stack SAP development, HANA inspection, Integration Suite (CPI/API Management/Developer Hub), SAP Datasphere, SAP Build Process Automation, SAP BTP platform administration, and SAP Cloud ALM.', 'step', output, 'Tools'));
1125
1721
  print(output, ' HANA Cloud inspector is read-only and uses HANA_RO_* or VCAP_SERVICES already present in the MCP host environment; credentials are not written to mcp.json.');
1126
1722
  print(output, ' Integration Suite uses CPI/APIM/DEVHUB service keys from the MCP host environment or keys you paste next (verified online, saved for your user only); credentials are not written to mcp.json.');
1723
+ print(output, ' SAP Datasphere uses a Datasphere OAuth client from DATASPHERE_* in the MCP host environment or entered next (verified online, saved for your user only); credentials are not written to mcp.json.');
1724
+ print(output, ' SAP Build Process Automation uses a service key from PROCESS_AUTOMATION_* in the MCP host environment or pasted next (verified online, saved for your user only); credentials are not written to mcp.json.');
1725
+ print(output, ' SAP BTP platform administration uses your own btp CLI login (btp login --sso), checked next; the toolkit stores no credentials and writes none to mcp.json.');
1726
+ print(output, ' SAP Cloud ALM uses a service key from CLOUD_ALM_* in the MCP host environment or pasted next (verified online, saved for your user only); credentials are not written to mcp.json.');
1127
1727
  print(output, ' Leave empty to only configure ABAP/ADT destination servers.');
1128
1728
  print(output, '');
1129
1729
  sapDevelopmentServers = await checkboxPrompt({
@@ -1139,6 +1739,18 @@ export async function runSetup({
1139
1739
  if (sapDevelopmentServers.includes('integration-suite')) {
1140
1740
  await promptIntegrationSuite({ env, alreadyConfigured: integrationSuiteConfigured, input, output, verifyIntegration, integrationCloudFoundry, ask: false });
1141
1741
  }
1742
+ if (sapDevelopmentServers.includes('datasphere') && !await promptDatasphere({ env, input, output })) {
1743
+ sapDevelopmentServers = sapDevelopmentServers.filter(id => id !== 'datasphere');
1744
+ }
1745
+ if (sapDevelopmentServers.includes('process-automation') && !await promptProcessAutomation({ env, input, output })) {
1746
+ sapDevelopmentServers = sapDevelopmentServers.filter(id => id !== 'process-automation');
1747
+ }
1748
+ if (sapDevelopmentServers.includes('sap-btp') && !await promptBtp({ env, input, output })) {
1749
+ sapDevelopmentServers = sapDevelopmentServers.filter(id => id !== 'sap-btp');
1750
+ }
1751
+ if (sapDevelopmentServers.includes('cloud-alm') && !await promptCloudAlm({ env, input, output })) {
1752
+ sapDevelopmentServers = sapDevelopmentServers.filter(id => id !== 'cloud-alm');
1753
+ }
1142
1754
  } else if (await promptIntegrationSuite({ env, alreadyConfigured: integrationSuiteConfigured, input, output, verifyIntegration, integrationCloudFoundry })) {
1143
1755
  // Without --tools, Integration Suite still gets its own yes/no step instead of being hidden.
1144
1756
  sapDevelopmentServers = ['integration-suite'];