sap-ai-dev-toolkit 0.9.9904 → 0.9.9908

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/src/abaplint.mjs CHANGED
@@ -1,5 +1,13 @@
1
+ import { lstat, readdir, readFile, realpath } from 'node:fs/promises';
2
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
1
3
  import { Config, MemoryFile, Registry } from '@abaplint/core';
2
4
 
5
+ const MAX_PROJECT_FILES = 20_000;
6
+ const MAX_PROJECT_FILE_BYTES = 2 * 1024 * 1024;
7
+ const MAX_PROJECT_SOURCE_BYTES = 32 * 1024 * 1024;
8
+ const IGNORED_DIRECTORIES = new Set(['.git', '.hg', '.svn', '.build', '.next', '.cache', 'coverage', 'dist', 'node_modules']);
9
+ const PROJECT_CONFIG_FILES = ['.abaplint.jsonc', 'abaplint.jsonc', '.abaplint.json', 'abaplint.json'];
10
+
3
11
  export const ABAP_LINT_TOOL = {
4
12
  name: 'LintABAP',
5
13
  description: 'Run abaplint on caller-supplied source files in memory; does not read workspace files or call SAP.',
@@ -26,6 +34,21 @@ export const ABAP_LINT_TOOL = {
26
34
  }
27
35
  };
28
36
 
37
+ export const ABAP_LINT_PROJECT_TOOL = {
38
+ name: 'LintABAPProject',
39
+ description: 'Run read-only abaplint checks over serialized ABAP objects under the configured workspace root. Loads the project config and local dependency folders when present; does not call SAP, write files, or activate ABAP.',
40
+ inputSchema: {
41
+ type: 'object',
42
+ properties: {
43
+ directory: { type: 'string', description: 'Optional project subdirectory relative to the configured workspace root.' },
44
+ config: { type: 'object', description: 'Optional abaplint config object that overrides project config discovery.' }
45
+ },
46
+ required: [],
47
+ additionalProperties: false
48
+ },
49
+ annotations: { readOnlyHint: true }
50
+ };
51
+
29
52
  function isObject(value) {
30
53
  return value !== null && typeof value === 'object' && !Array.isArray(value);
31
54
  }
@@ -65,6 +88,170 @@ function errorResult(error) {
65
88
  };
66
89
  }
67
90
 
91
+ function reportResult(report) {
92
+ return {
93
+ isError: false,
94
+ content: [{ type: 'text', text: JSON.stringify(report) }],
95
+ structuredContent: report
96
+ };
97
+ }
98
+
99
+ function lintReport(registry, filesChecked) {
100
+ const issues = registry.findIssues();
101
+ const counts = { Error: 0, Warning: 0, Info: 0 };
102
+ const reportIssues = issues.map(issue => {
103
+ counts[issue.getSeverity()] += 1;
104
+ const start = issue.getStart();
105
+ const end = issue.getEnd();
106
+ return {
107
+ filename: issue.getFilename(),
108
+ rule: issue.getKey(),
109
+ severity: issue.getSeverity(),
110
+ message: issue.getMessage(),
111
+ start: { line: start.getRow(), column: start.getCol() },
112
+ end: { line: end.getRow(), column: end.getCol() }
113
+ };
114
+ });
115
+ return {
116
+ status: reportIssues.length === 0 ? 'clean' : 'issues',
117
+ filesChecked,
118
+ issueCount: reportIssues.length,
119
+ errors: counts.Error,
120
+ warnings: counts.Warning,
121
+ infos: counts.Info,
122
+ issues: reportIssues
123
+ };
124
+ }
125
+
126
+ function pathIsWithin(parent, candidate) {
127
+ const path = relative(parent, candidate);
128
+ return path === '' || (path !== '..' && !path.startsWith(`..${sep}`) && !isAbsolute(path));
129
+ }
130
+
131
+ function validateProjectArguments(arguments_) {
132
+ if (!isObject(arguments_)) throw new Error('Arguments must be an object');
133
+ rejectUnknownFields(arguments_, new Set(['directory', 'config']), 'argument');
134
+ if (arguments_.directory !== undefined) {
135
+ if (typeof arguments_.directory !== 'string' || arguments_.directory.includes('\0') ||
136
+ isAbsolute(arguments_.directory) || /^[A-Za-z]:[\\/]/.test(arguments_.directory) ||
137
+ arguments_.directory.split(/[\\/]+/).includes('..')) {
138
+ throw new Error('directory must be a relative path inside the configured workspace root');
139
+ }
140
+ }
141
+ if (arguments_.config !== undefined && !isObject(arguments_.config)) {
142
+ throw new Error('config must be an object');
143
+ }
144
+ }
145
+
146
+ async function loadProjectConfig(directory, override) {
147
+ if (override !== undefined) return { config: new Config(JSON.stringify(override)), source: 'caller' };
148
+ for (const filename of PROJECT_CONFIG_FILES) {
149
+ const configPath = resolve(directory, filename);
150
+ let info;
151
+ try { info = await lstat(configPath); }
152
+ catch (error) {
153
+ if (error?.code === 'ENOENT') continue;
154
+ throw error;
155
+ }
156
+ if (info.isSymbolicLink() || !info.isFile()) throw new Error(`${filename} must be a regular file inside the workspace`);
157
+ try {
158
+ return { config: new Config(await readFile(configPath, 'utf8')), source: filename };
159
+ } catch (error) {
160
+ throw new Error(`Could not load ${filename}: ${error.message || error}`);
161
+ }
162
+ }
163
+ return { config: Config.getDefault(), source: 'default' };
164
+ }
165
+
166
+ function enableSyntaxRules(config) {
167
+ const projectConfig = config.get();
168
+ const rules = { ...(projectConfig.rules || {}) };
169
+ const disabled = [];
170
+ for (const rule of ['parser_error', 'check_syntax']) {
171
+ if (rules[rule] === false) disabled.push(rule);
172
+ else if (rules[rule] === undefined) rules[rule] = true;
173
+ }
174
+ return {
175
+ config: new Config(JSON.stringify({ ...projectConfig, rules })),
176
+ disabled
177
+ };
178
+ }
179
+
180
+ async function dependencyRoots(config, projectRoot) {
181
+ const declared = config.get()?.dependencies;
182
+ if (!Array.isArray(declared)) return { roots: [], missing: [], count: 0 };
183
+ const roots = [];
184
+ const missing = [];
185
+ for (const dependency of declared) {
186
+ const folder = dependency?.folder;
187
+ if (typeof folder !== 'string' || !folder.trim()) {
188
+ missing.push(String(folder || '(unspecified folder)'));
189
+ continue;
190
+ }
191
+ const relativeFolder = folder.replace(/^[\\/]+/, '');
192
+ if (/^[A-Za-z]:[\\/]/.test(relativeFolder) || relativeFolder.split(/[\\/]+/).includes('..')) {
193
+ throw new Error('Dependency folders must stay inside the configured project directory');
194
+ }
195
+ const candidate = resolve(projectRoot, relativeFolder || '.');
196
+ if (!pathIsWithin(projectRoot, candidate)) throw new Error('Dependency folders must stay inside the configured project directory');
197
+ let canonical;
198
+ try { canonical = await realpath(candidate); }
199
+ catch (error) {
200
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') {
201
+ missing.push(folder);
202
+ continue;
203
+ }
204
+ throw error;
205
+ }
206
+ if (!pathIsWithin(projectRoot, canonical)) throw new Error('Dependency folders must stay inside the configured project directory');
207
+ const info = await lstat(canonical);
208
+ if (!info.isDirectory()) {
209
+ missing.push(folder);
210
+ continue;
211
+ }
212
+ roots.push(canonical);
213
+ }
214
+ return { roots, missing, count: declared.length };
215
+ }
216
+
217
+ async function collectProjectFiles(projectRoot, dependencies) {
218
+ const sourceFiles = [];
219
+ const dependencyFiles = [];
220
+ const pending = [projectRoot];
221
+ let totalBytes = 0;
222
+ while (pending.length) {
223
+ const directory = pending.pop();
224
+ const entries = await readdir(directory, { withFileTypes: true });
225
+ entries.sort((left, right) => left.name.localeCompare(right.name));
226
+ for (const entry of entries) {
227
+ const absolutePath = resolve(directory, entry.name);
228
+ if (entry.isDirectory()) {
229
+ const isDependencyPath = dependencies.some(root => pathIsWithin(absolutePath, root) || pathIsWithin(root, absolutePath));
230
+ if (!IGNORED_DIRECTORIES.has(entry.name) || isDependencyPath) pending.push(absolutePath);
231
+ continue;
232
+ }
233
+ if (!entry.isFile()) continue;
234
+ const filename = relative(projectRoot, absolutePath).split(sep).join('/');
235
+ const basename = filename.split('/').at(-1);
236
+ const nameParts = basename.split('.');
237
+ if (nameParts.length < 3 || !nameParts[0] || !nameParts[1]) continue;
238
+ if (!new MemoryFile(filename, '').getObjectType()) continue;
239
+ const info = await lstat(absolutePath);
240
+ if (!info.isFile() || info.isSymbolicLink()) continue;
241
+ if (info.size > MAX_PROJECT_FILE_BYTES) throw new Error(`ABAP file exceeds ${MAX_PROJECT_FILE_BYTES} bytes: ${filename}`);
242
+ totalBytes += info.size;
243
+ if (totalBytes > MAX_PROJECT_SOURCE_BYTES) throw new Error(`Workspace ABAP sources exceed ${MAX_PROJECT_SOURCE_BYTES} bytes`);
244
+ const file = new MemoryFile(filename, await readFile(absolutePath, 'utf8'));
245
+ const isDependency = dependencies.some(root => pathIsWithin(root, absolutePath));
246
+ (isDependency ? dependencyFiles : sourceFiles).push(file);
247
+ if (sourceFiles.length + dependencyFiles.length > MAX_PROJECT_FILES) {
248
+ throw new Error(`Workspace contains more than ${MAX_PROJECT_FILES} ABAP files`);
249
+ }
250
+ }
251
+ }
252
+ return { sourceFiles, dependencyFiles };
253
+ }
254
+
68
255
  export async function runABAPLint(arguments_) {
69
256
  try {
70
257
  validateArguments(arguments_);
@@ -74,31 +261,49 @@ export async function runABAPLint(arguments_) {
74
261
  const files = arguments_.files.map(({ filename, source }) => new MemoryFile(filename, source));
75
262
  const registry = new Registry(config).addFiles(files);
76
263
  await registry.parseAsync();
77
- const issues = registry.findIssues();
78
- const counts = { Error: 0, Warning: 0, Info: 0 };
79
- const reportIssues = issues.map(issue => {
80
- counts[issue.getSeverity()] += 1;
81
- const start = issue.getStart();
82
- const end = issue.getEnd();
83
- return {
84
- filename: issue.getFilename(),
85
- rule: issue.getKey(),
86
- severity: issue.getSeverity(),
87
- message: issue.getMessage(),
88
- start: { line: start.getRow(), column: start.getCol() },
89
- end: { line: end.getRow(), column: end.getCol() }
90
- };
264
+ return { isError: false, content: [{ type: 'text', text: JSON.stringify(lintReport(registry, files.length)) }] };
265
+ } catch (error) {
266
+ return errorResult(error);
267
+ }
268
+ }
269
+
270
+ export async function runABAPLintProject(arguments_, { workspaceRoot = process.cwd() } = {}) {
271
+ try {
272
+ validateProjectArguments(arguments_);
273
+ const root = await realpath(workspaceRoot);
274
+ const directory = resolve(root, arguments_.directory || '.');
275
+ if (!pathIsWithin(root, directory)) throw new Error('directory must stay inside the configured workspace root');
276
+ const projectRoot = await realpath(directory);
277
+ if (!pathIsWithin(root, projectRoot)) throw new Error('directory must stay inside the configured workspace root');
278
+ const projectInfo = await lstat(projectRoot);
279
+ if (!projectInfo.isDirectory()) throw new Error('directory must identify a workspace directory');
280
+
281
+ const { config: projectConfig, source: configSource } = await loadProjectConfig(projectRoot, arguments_.config);
282
+ const { config, disabled: disabledSyntaxRules } = enableSyntaxRules(projectConfig);
283
+ const dependencies = await dependencyRoots(config, projectRoot);
284
+ const { sourceFiles, dependencyFiles } = await collectProjectFiles(projectRoot, dependencies.roots);
285
+ const registry = new Registry(config);
286
+ registry.addDependencies(dependencyFiles);
287
+ registry.addFiles(sourceFiles);
288
+ await registry.parseAsync();
289
+
290
+ const report = lintReport(registry, sourceFiles.length);
291
+ if (report.filesChecked === 0) report.status = 'no-abap-files';
292
+ else if (report.status === 'clean' && (dependencies.missing.length || disabledSyntaxRules.length)) report.status = 'incomplete';
293
+ return reportResult({
294
+ ...report,
295
+ scope: 'workspace-static',
296
+ activation: 'not-performed',
297
+ workspaceDirectory: arguments_.directory || '.',
298
+ configSource,
299
+ targetRelease: config.getVersion(),
300
+ syntaxRules: { enabled: ['parser_error', 'check_syntax'].filter(rule => !disabledSyntaxRules.includes(rule)), disabled: disabledSyntaxRules },
301
+ dependencies: {
302
+ declared: dependencies.count,
303
+ filesLoaded: dependencyFiles.length,
304
+ missingFolders: dependencies.missing
305
+ }
91
306
  });
92
- const report = {
93
- status: reportIssues.length === 0 ? 'clean' : 'issues',
94
- filesChecked: files.length,
95
- issueCount: reportIssues.length,
96
- errors: counts.Error,
97
- warnings: counts.Warning,
98
- infos: counts.Info,
99
- issues: reportIssues
100
- };
101
- return { isError: false, content: [{ type: 'text', text: JSON.stringify(report) }] };
102
307
  } catch (error) {
103
308
  return errorResult(error);
104
309
  }
@@ -7,7 +7,7 @@ import { decodeJsonFileBuffer } from './mcp-config.mjs';
7
7
 
8
8
  const MANAGED_ENV = 'SAP_AI_DEV_TOOLKIT_MANAGED';
9
9
 
10
- function mcpServersPath(harness, { home = process.env.HOME || homedir(), env = process.env } = {}) {
10
+ function mcpServersPath(harness, { home = process.env.HOME || homedir(), env = process.env, platform = process.platform } = {}) {
11
11
  switch (harness.id) {
12
12
  case 'claude-code':
13
13
  return join(home, '.claude.json');
@@ -15,8 +15,16 @@ function mcpServersPath(harness, { home = process.env.HOME || homedir(), env = p
15
15
  return join(harnessRoot(harness, { home, env }), 'mcp.json');
16
16
  case 'gemini-cli':
17
17
  return join(harnessRoot(harness, { home, env }), 'settings.json');
18
+ case 'omp':
19
+ return join(harnessRoot(harness, { home, env }), 'mcp.json');
18
20
  case 'pi-coding-agent':
19
21
  return join(harnessRoot(harness, { home, env }), 'mcp.json');
22
+ case 'windsurf': {
23
+ const configRoot = platform === 'win32'
24
+ ? (typeof env?.APPDATA === 'string' && env.APPDATA.trim() ? env.APPDATA.trim() : join(home, 'AppData', 'Roaming'))
25
+ : (typeof env?.XDG_CONFIG_HOME === 'string' && env.XDG_CONFIG_HOME.trim() ? env.XDG_CONFIG_HOME.trim() : join(home, '.config'));
26
+ return join(configRoot, 'devin', 'mcp_config.json');
27
+ }
20
28
  default:
21
29
  return null;
22
30
  }
@@ -0,0 +1,202 @@
1
+ import { open } from 'node:fs/promises';
2
+ import { closeSync, openSync } from 'node:fs';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { dirname, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { homedir } from 'node:os';
7
+ import { ReadStream as TTYReadStream, WriteStream as TTYWriteStream } from 'node:tty';
8
+ import { installMcpServersForHarnesses } from './harness-mcp-config.mjs';
9
+ import { installUserAssetsForHarnesses } from './install-user-assets.mjs';
10
+ import { DEFAULT_HARNESS_IDS, HARNESSES, HARNESS_ENV_VAR, NON_INTERACTIVE_HARNESS_IDS, envHarnessSelection, harnessRoot } from './harnesses.mjs';
11
+ import { checkboxPrompt, colorText, formatStatus } from './terminal-ui.mjs';
12
+
13
+ const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
14
+
15
+ export async function announce(message, tone = 'info') {
16
+ const content = message.replace(/^sap-ai-dev-toolkit:\s*/, '');
17
+ // Windows cannot open CONOUT$ as a writable console stream from a
18
+ // piped npm script (libuv normalizes the device name, and the resulting
19
+ // handle drops writes), so output goes through stderr unconditionally.
20
+ if (process.platform === 'win32') {
21
+ console.error(formatStatus(content, tone, process.stderr));
22
+ return;
23
+ }
24
+ let terminal;
25
+ try {
26
+ terminal = await open('/dev/tty', 'w');
27
+ await terminal.write(`${formatStatus(content, tone, true)}\n`);
28
+ } catch {
29
+ console.error(formatStatus(content, tone, process.stderr));
30
+ } finally {
31
+ await terminal?.close().catch(() => {});
32
+ }
33
+ }
34
+
35
+ function openControllingTerminal() {
36
+ if (process.platform === 'win32') {
37
+ // npm can pipe lifecycle script streams even when the install was
38
+ // launched from an interactive Windows console. Verify console access,
39
+ // then attach Node TTY streams to its standard console devices.
40
+ const probe = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', '[Console]::OpenStandardInput(); [Console]::OpenStandardOutput()'], { stdio: 'ignore', windowsHide: true });
41
+ if (probe.error || probe.status !== 0) throw new Error('interactive console is unavailable');
42
+ const inputFd = openSync('CONIN$', 'r');
43
+ let outputFd;
44
+ try {
45
+ outputFd = openSync('CONOUT$', 'w');
46
+ return { input: new TTYReadStream(inputFd), output: new TTYWriteStream(outputFd) };
47
+ } catch (error) {
48
+ closeSync(inputFd);
49
+ if (outputFd !== undefined) closeSync(outputFd);
50
+ throw error;
51
+ }
52
+ }
53
+ const inputFd = openSync('/dev/tty', 'r');
54
+ let outputFd;
55
+ try {
56
+ outputFd = openSync('/dev/tty', 'w');
57
+ return { input: new TTYReadStream(inputFd), output: new TTYWriteStream(outputFd) };
58
+ } catch (error) {
59
+ closeSync(inputFd);
60
+ if (outputFd !== undefined) closeSync(outputFd);
61
+ throw error;
62
+ }
63
+ }
64
+
65
+ export async function withInstallTerminal(action) {
66
+ if (process.stdin.isTTY && process.stdout.isTTY) {
67
+ return action({ input: process.stdin, output: process.stdout });
68
+ }
69
+ let terminal;
70
+ try {
71
+ terminal = openControllingTerminal();
72
+ } catch {
73
+ return action(null);
74
+ }
75
+ try {
76
+ return await action(terminal);
77
+ } finally {
78
+ terminal.input.destroy();
79
+ terminal.output.destroy();
80
+ }
81
+ }
82
+
83
+ function harnessSelectionLabels(ids) {
84
+ return HARNESSES.filter(harness => ids.includes(harness.id)).map(harness => harness.label).join(', ');
85
+ }
86
+
87
+ async function resolveHarnessOverride(env) {
88
+ try {
89
+ return envHarnessSelection(env);
90
+ } catch (error) {
91
+ await announce(`${error.message} Ignoring ${HARNESS_ENV_VAR}.`, 'warning');
92
+ return null;
93
+ }
94
+ }
95
+
96
+ async function installSelectedHarnessAssets(ids, { env, home, root }) {
97
+ const { results, failures } = await installUserAssetsForHarnesses(ids, { env, home, root });
98
+ for (const failure of failures) {
99
+ await announce(`${failure.label}: bundled agent and skill installation failed: ${failure.error.message}`, 'error');
100
+ }
101
+ for (const assets of results) {
102
+ const installed = assets.installed + assets.updated;
103
+ const removed = assets.removed || 0;
104
+ const cleanup = removed > 0 ? `; ${removed} duplicate or legacy file${removed === 1 ? '' : 's'} removed` : '';
105
+ await announce(`${assets.label}: ${installed} file${installed === 1 ? '' : 's'} installed or updated in ${assets.root}; ${assets.unchanged} already current${cleanup}.`, 'success');
106
+ if (assets.conflicts.length > 0) {
107
+ const paths = assets.conflicts.map(path => join(assets.root, path)).join(', ');
108
+ await announce(`${assets.label}: existing customizations were preserved; review these paths: ${paths}`, 'warning');
109
+ }
110
+ }
111
+ const total = results.reduce((sum, assets) => sum + assets.installed + assets.updated, 0);
112
+ if (results.length > 0) {
113
+ await announce(`Installed ${total} files across ${results.length} harness${results.length === 1 ? '' : 'es'}.`, 'success');
114
+ }
115
+ }
116
+
117
+ async function runHarnessAssetInstall({ env = process.env, root = packageRoot } = {}) {
118
+ const home = env.HOME || homedir();
119
+ const override = await resolveHarnessOverride(env);
120
+ return withInstallTerminal(async terminal => {
121
+ let selected = override;
122
+ if (!terminal) {
123
+ if (!selected) selected = NON_INTERACTIVE_HARNESS_IDS;
124
+ await announce(`Installing bundled agents and skills for ${harnessSelectionLabels(selected)} by default because no interactive terminal is available.`, 'progress');
125
+ await installSelectedHarnessAssets(selected, { env, home, root });
126
+ return selected;
127
+ }
128
+ if (!selected) selected = DEFAULT_HARNESS_IDS;
129
+ await announce([
130
+ 'The bundled agents and skills can be installed for several AI coding harnesses.',
131
+ '',
132
+ 'Space = select or deselect · a = toggle all · Enter = confirm. Pressing Enter without changes installs the pre-checked harnesses; confirming with none selected skips this step.',
133
+ '',
134
+ `${colorText('📁 Target folders:', 'cyan', true)}`,
135
+ ...HARNESSES.map(harness => ` ${harnessRoot(harness, { home, env })} — ${harness.label} (${harness.supportsAgents ? 'skills + agents' : 'skills only'})`),
136
+ ''
137
+ ].join('\n'), 'copilot');
138
+ let answer;
139
+ try {
140
+ answer = await checkboxPrompt({
141
+ message: colorText('🤖 Install bundled agents and skills for', 'magenta', terminal.output),
142
+ choices: HARNESSES.map(harness => ({
143
+ value: harness.id,
144
+ name: `${harness.label} (${harness.supportsAgents ? 'skills + agents' : 'skills'})`,
145
+ checked: selected.includes(harness.id)
146
+ })),
147
+ required: false,
148
+ shortcuts: { all: 'a' }
149
+ }, { input: terminal.input, output: terminal.output });
150
+ } catch (error) {
151
+ if (error.message === 'Prompt interrupted') {
152
+ await announce('Bundled agents and skills were skipped because the prompt was interrupted. Your files were not changed.', 'info');
153
+ return [];
154
+ }
155
+ throw error;
156
+ }
157
+ if (answer.length === 0) {
158
+ await announce('Bundled agents and skills were skipped. Your files were not changed.', 'info');
159
+ return [];
160
+ }
161
+ await installSelectedHarnessAssets(answer, { env, home, root });
162
+ return answer;
163
+ });
164
+ }
165
+
166
+ async function wireHarnessMcpServers(ids, servers, env) {
167
+ const entries = servers && Object.keys(servers).length ? servers : null;
168
+ if (!entries || !ids?.length) return;
169
+ const hasLocalSapGui = Object.values(entries).some(entry => entry?.env?.SAP_AI_DEV_TOOLKIT_DESTINATION_SOURCE === 'sap-gui-local');
170
+ if (hasLocalSapGui) {
171
+ 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');
172
+ return;
173
+ }
174
+ const { results, failures } = await installMcpServersForHarnesses(ids, entries, { env });
175
+ for (const result of results) {
176
+ if (!result.supported) {
177
+ await announce(`${result.label}: MCP auto-wiring skipped. ${result.skipped}`, 'info');
178
+ continue;
179
+ }
180
+ await announce(`${result.label}: wired ${result.servers} MCP server${result.servers === 1 ? '' : 's'} in ${result.path}.`, 'success');
181
+ }
182
+ for (const failure of failures) {
183
+ await announce(`${failure.label}: MCP auto-wiring failed: ${failure.error.message}`, 'error');
184
+ }
185
+ }
186
+
187
+ export async function runHarnessSetup(servers, { env = process.env, root = packageRoot, wire = true } = {}) {
188
+ let ids = [];
189
+ try {
190
+ ids = await runHarnessAssetInstall({ env, root });
191
+ } catch (error) {
192
+ await announce(`Bundled agent and skill installation failed: ${error.message}`, 'error');
193
+ }
194
+ if (wire) {
195
+ try {
196
+ await wireHarnessMcpServers(ids, servers, env);
197
+ } catch (error) {
198
+ await announce(`Harness MCP auto-wiring failed: ${error.message}`, 'error');
199
+ }
200
+ }
201
+ return ids;
202
+ }
package/src/harnesses.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { homedir } from 'node:os';
2
- import { join } from 'node:path';
2
+ import { join, resolve } from 'node:path';
3
3
 
4
4
  export const HARNESS_ENV_VAR = 'SAP_AI_DEV_TOOLKIT_HARNESSES';
5
5
  export const DEFAULT_HARNESS_IDS = ['github-copilot', 'claude-code'];
@@ -38,6 +38,19 @@ export function piCodingAgentRoot({ home = process.env.HOME || homedir(), env =
38
38
  return override || join(home, '.pi', 'agent');
39
39
  }
40
40
 
41
+ export function ompAgentRoot({ home = process.env.HOME || homedir(), env = process.env } = {}) {
42
+ const agentOverride = typeof env?.PI_CODING_AGENT_DIR === 'string' ? env.PI_CODING_AGENT_DIR.trim() : '';
43
+ if (agentOverride) return resolve(agentOverride);
44
+ const configDirectory = typeof env?.PI_CONFIG_DIR === 'string' ? env.PI_CONFIG_DIR.trim() : '';
45
+ return join(home, configDirectory || '.omp', 'agent');
46
+ }
47
+
48
+ export function windsurfSkillsRoot({ home = process.env.HOME || homedir(), env = process.env, platform = process.platform } = {}) {
49
+ if (platform === 'win32') return join(home, '.codeium', 'windsurf');
50
+ const override = typeof env?.XDG_CONFIG_HOME === 'string' ? env.XDG_CONFIG_HOME.trim() : '';
51
+ return join(override || join(home, '.config'), 'devin');
52
+ }
53
+
41
54
  export function claudeCodeAgentDocument(source) {
42
55
  const frontmatter = splitFrontmatter(source);
43
56
  if (!frontmatter) throw new Error('unsupported agent frontmatter');
@@ -86,7 +99,11 @@ export const HARNESSES = [
86
99
  { id: 'cursor', label: 'Cursor', directory: '.cursor', supportsAgents: false },
87
100
  { id: 'gemini-cli', label: 'Gemini CLI', directory: '.gemini', supportsAgents: false },
88
101
  { id: 'opencode', label: 'opencode', directory: 'opencode', configHome: true, supportsAgents: true, agentFileName: agentMarkdownName, agentDocument: opencodeAgentDocument },
89
- { id: 'pi-coding-agent', label: 'Pi Coding Agent', directory: '.pi/agent', supportsAgents: true, agentTargetPrefix: 'prompts', agentFileName: agentMarkdownName, agentDocument: piCodingAgentPromptDocument, root: piCodingAgentRoot }
102
+ { id: 'pi-coding-agent', label: 'Pi Coding Agent', directory: '.pi/agent', supportsAgents: true, agentTargetPrefix: 'prompts', agentFileName: agentMarkdownName, agentDocument: piCodingAgentPromptDocument, root: piCodingAgentRoot },
103
+ { id: 'omp', label: 'Oh My Pi (OMP)', directory: '.omp/agent', supportsAgents: false, root: ompAgentRoot },
104
+ { id: 'cline', label: 'Cline', directory: '.cline', supportsAgents: false },
105
+ { id: 'roo-code', label: 'Roo Code', directory: '.roo', supportsAgents: false },
106
+ { id: 'windsurf', label: 'Windsurf / Devin Desktop', directory: 'devin', supportsAgents: false, root: windsurfSkillsRoot }
90
107
  ];
91
108
 
92
109
  export function harnessById(id) {
@@ -97,8 +114,8 @@ export function harnessById(id) {
97
114
  return harness;
98
115
  }
99
116
 
100
- export function harnessRoot(harness, { home = process.env.HOME || homedir(), env = process.env } = {}) {
101
- if (harness.root) return harness.root({ home, env });
117
+ export function harnessRoot(harness, { home = process.env.HOME || homedir(), env = process.env, platform = process.platform } = {}) {
118
+ if (harness.root) return harness.root({ home, env, platform });
102
119
  if (harness.configHome) {
103
120
  const override = typeof env?.XDG_CONFIG_HOME === 'string' ? env.XDG_CONFIG_HOME.trim() : '';
104
121
  return join(override || join(home, '.config'), harness.directory);
package/src/launcher.mjs CHANGED
@@ -11,6 +11,7 @@ import { binaryTarget, cacheDirectory, downloadBinary, findBinary } from './bina
11
11
  import { MCPProxy } from './mcp-proxy.mjs';
12
12
  import { configuredVersionHandoff, installMcpConfig, npxPackageLauncher, repairManagedMcpConfig } from './mcp-config.mjs';
13
13
  import { runSetup } from './setup.mjs';
14
+ import { runHarnessSetup } from './harness-setup.mjs';
14
15
  import { resolveConfiguredCloudFoundryDestination } from './cf-destination.mjs';
15
16
  import { createTlsServerNameAdtProxy } from './tls-adt-proxy.mjs';
16
17
  import { findSsoBrowsers } from './sso-browsers.mjs';
@@ -106,7 +107,7 @@ async function runDoctor(destinations) {
106
107
  // unprefixed (get_source, run_query); match the slug-prefixed form too
107
108
  // in case this ever runs against a multi-destination server.
108
109
  const toolPrefix = `${slugifyDestination(destination.name)}_`;
109
- const localSegments = new Set(['lint_abap', 'get_application_log', 'prepare_abap_change_set', 'apply_abap_change_set', 'check_transport_readiness', 'plan_abap_cloud_migration', 'generate_rap_regression_suite', 'run_rap_regression_suite']);
110
+ const localSegments = new Set(['lint_abap', 'lint_abap_project', 'get_application_log', 'prepare_abap_change_set', 'apply_abap_change_set', 'check_transport_readiness', 'plan_abap_cloud_migration', 'generate_rap_regression_suite', 'run_rap_regression_suite']);
110
111
  const isUpstreamTool = tool => (tool.name.startsWith(toolPrefix) ? tool.name.slice(toolPrefix.length) : tool.name);
111
112
  const upstreamCount = tools.filter(tool => !localSegments.has(isUpstreamTool(tool))).length;
112
113
  checks.push(doctorRow(destination.name, 'MCP tools/list', upstreamCount ? 'passed' : 'failed', `${upstreamCount} upstream tools and ${tools.length} total MCP tools returned; chat-picker binding is host-managed`));
@@ -415,7 +416,8 @@ async function main() {
415
416
  const launcher = npxPackageLauncher(pkg.version);
416
417
  setupOptions.install = (selected, options) => installMcpConfig(selected, { ...options, ...launcher });
417
418
  }
418
- await runSetup(setupOptions);
419
+ const result = await runSetup(setupOptions);
420
+ await runHarnessSetup(result?.servers, { env: runtimeEnv, root });
419
421
  return;
420
422
  }
421
423
  if (check || list) {
@@ -460,7 +462,8 @@ async function main() {
460
462
  if (!runtimeEnv.H2O_URL) {
461
463
  if (args().length === 0 && process.stdin.isTTY && process.stdout.isTTY) {
462
464
  console.error('sap-ai-dev: no BAS environment detected; starting local interactive setup. Use --help for other commands.');
463
- await runSetup({ detectAdtUrl: detectLocalAdtUrl });
465
+ const result = await runSetup({ detectAdtUrl: detectLocalAdtUrl });
466
+ await runHarnessSetup(result?.servers, { env: runtimeEnv, root });
464
467
  return;
465
468
  }
466
469
  const binary = await binaryOrError();
package/src/mcp-proxy.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { spawn as nodeSpawn } from 'node:child_process';
2
2
  import { once } from 'node:events';
3
3
  import { sanitizeChildEnv, slugifyDestination } from './bas-discovery.mjs';
4
- import { ABAP_LINT_TOOL, runABAPLint } from './abaplint.mjs';
4
+ import { ABAP_LINT_PROJECT_TOOL, ABAP_LINT_TOOL, runABAPLint, runABAPLintProject } from './abaplint.mjs';
5
5
  import { createEngineeringTools } from './engineering-tools.mjs';
6
6
  import { brandedEnvValue } from './branding.mjs';
7
7
  import { diagnosticText, redactText } from './redact.mjs';
@@ -698,6 +698,12 @@ export class MCPProxy {
698
698
  });
699
699
  }
700
700
  }
701
+ const projectLintName = snakeCaseName(ABAP_LINT_PROJECT_TOOL.name);
702
+ publish(projectLintName, {
703
+ handler: arguments_ => runABAPLintProject(arguments_, {
704
+ workspaceRoot: brandedEnvValue(this.env, 'WORKSPACE_ROOT') || process.cwd()
705
+ })
706
+ }, { ...ABAP_LINT_PROJECT_TOOL, name: projectLintName });
701
707
  if (!merged.length && this.children.length) throw new Error('No destination child provided tools');
702
708
  // Swap atomically so a concurrent tools/call never sees a partial map.
703
709
  this.namespace = namespace;