devtopia 1.9.0 → 2.0.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/dist/executor.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { spawn } from 'child_process';
2
- import { writeFileSync, mkdirSync, rmSync, existsSync } from 'fs';
3
- import { join } from 'path';
2
+ import { writeFileSync, mkdirSync, rmSync, existsSync, chmodSync, readFileSync } from 'fs';
3
+ import { join, dirname, extname } from 'path';
4
4
  import { tmpdir } from 'os';
5
5
  import { randomUUID } from 'crypto';
6
6
  import { API_BASE } from './config.js';
7
+ import { fileURLToPath } from 'url';
8
+ import { createRequire } from 'module';
7
9
  /**
8
10
  * Fetch a tool from the registry
9
11
  */
@@ -18,115 +20,578 @@ async function fetchTool(name) {
18
20
  /**
19
21
  * Get the interpreter command for a language
20
22
  */
21
- function getInterpreter(language) {
23
+ function getToolExtension(language) {
22
24
  switch (language) {
23
25
  case 'typescript':
24
- return { cmd: 'npx', args: ['tsx'], ext: '.ts' };
26
+ return '.ts';
25
27
  case 'javascript':
26
- return { cmd: 'node', args: [], ext: '.js' };
28
+ return '.js';
27
29
  case 'python':
28
- return { cmd: 'python3', args: [], ext: '.py' };
30
+ return '.py';
31
+ case 'bash':
32
+ return '.sh';
33
+ case 'ruby':
34
+ return '.rb';
35
+ case 'php':
36
+ return '.php';
37
+ case 'shebang':
38
+ return '.tool';
29
39
  default:
30
40
  throw new Error(`Unsupported language: ${language}`);
31
41
  }
32
42
  }
43
+ const __dirname = dirname(fileURLToPath(import.meta.url));
44
+ const require = createRequire(import.meta.url);
45
+ function resolveTsxRunner() {
46
+ const envTsx = process.env.DEVTOPIA_TSX;
47
+ if (envTsx) {
48
+ try {
49
+ if (existsSync(envTsx))
50
+ return { cmd: envTsx, args: [] };
51
+ }
52
+ catch { }
53
+ }
54
+ try {
55
+ const cliPath = require.resolve('tsx/dist/cli.mjs');
56
+ return { cmd: process.execPath, args: [cliPath] };
57
+ }
58
+ catch { }
59
+ const candidates = [
60
+ join(__dirname, '..', 'node_modules', '.bin', 'tsx'),
61
+ join(__dirname, '..', '..', 'node_modules', '.bin', 'tsx'),
62
+ join(process.cwd(), 'node_modules', '.bin', 'tsx'),
63
+ ];
64
+ for (const candidate of candidates) {
65
+ try {
66
+ if (existsSync(candidate))
67
+ return { cmd: candidate, args: [] };
68
+ }
69
+ catch { }
70
+ }
71
+ return null;
72
+ }
73
+ function resolveCliArgsForRuntime() {
74
+ const cliPath = process.argv[1];
75
+ if (!cliPath)
76
+ return null;
77
+ if (cliPath.endsWith('.ts')) {
78
+ const tsxRunner = resolveTsxRunner();
79
+ if (tsxRunner) {
80
+ return [tsxRunner.cmd, ...tsxRunner.args, cliPath];
81
+ }
82
+ return ['npx', 'tsx', cliPath];
83
+ }
84
+ return [process.execPath, cliPath];
85
+ }
86
+ function detectShebangLanguage(source) {
87
+ const firstLine = source.split('\n')[0].trim();
88
+ if (!firstLine.startsWith('#!'))
89
+ return null;
90
+ const cleaned = firstLine.replace(/^#!\s*/, '');
91
+ const parts = cleaned.split(/\s+/);
92
+ const exe = parts[0] || '';
93
+ const isEnv = exe.endsWith('/env');
94
+ const bin = isEnv ? (parts[1] || '') : exe;
95
+ const raw = bin.split('/').pop() || '';
96
+ const normalized = raw.replace(/[0-9.]+$/g, '');
97
+ if (normalized === 'node' || normalized === 'nodejs')
98
+ return 'javascript';
99
+ if (normalized === 'python' || normalized === 'python3')
100
+ return 'python';
101
+ if (normalized === 'bash' || normalized === 'sh')
102
+ return 'bash';
103
+ if (normalized === 'ruby')
104
+ return 'ruby';
105
+ if (normalized === 'php')
106
+ return 'php';
107
+ return 'shebang';
108
+ }
109
+ function detectLanguageFromFile(filePath, source) {
110
+ const ext = extname(filePath);
111
+ const EXT_MAP = {
112
+ '.ts': 'typescript',
113
+ '.js': 'javascript',
114
+ '.py': 'python',
115
+ '.mjs': 'javascript',
116
+ '.cjs': 'javascript',
117
+ '.sh': 'bash',
118
+ '.bash': 'bash',
119
+ '.rb': 'ruby',
120
+ '.php': 'php',
121
+ };
122
+ if (EXT_MAP[ext])
123
+ return EXT_MAP[ext];
124
+ const shebangLang = detectShebangLanguage(source);
125
+ return shebangLang || null;
126
+ }
127
+ function getRunner(language, toolFile) {
128
+ switch (language) {
129
+ case 'typescript':
130
+ {
131
+ const tsxRunner = resolveTsxRunner();
132
+ return tsxRunner
133
+ ? { cmd: tsxRunner.cmd, args: [...tsxRunner.args, toolFile] }
134
+ : { cmd: 'npx', args: ['tsx', toolFile] };
135
+ }
136
+ case 'javascript':
137
+ return { cmd: 'node', args: [toolFile] };
138
+ case 'python':
139
+ return { cmd: 'python3', args: [toolFile] };
140
+ case 'bash':
141
+ return { cmd: 'bash', args: [toolFile] };
142
+ case 'ruby':
143
+ return { cmd: 'ruby', args: [toolFile] };
144
+ case 'php':
145
+ return { cmd: 'php', args: [toolFile] };
146
+ case 'shebang':
147
+ return { cmd: toolFile, args: [] };
148
+ default:
149
+ throw new Error(`Unsupported language: ${language}`);
150
+ }
151
+ }
152
+ export function getInterpreter(language, toolFile) {
153
+ const ext = getToolExtension(language);
154
+ const file = toolFile || `tool${ext}`;
155
+ const { cmd, args } = getRunner(language, file);
156
+ return { cmd, args, ext };
157
+ }
33
158
  /**
34
- * Execute a tool locally
159
+ * Detect if source defines a `function main(...)` without calling it,
160
+ * and wrap it with the standard argv boilerplate so it actually executes.
35
161
  */
36
- export async function executeTool(toolName, input) {
162
+ export function wrapSourceIfNeeded(source, language) {
163
+ if (language === 'javascript' || language === 'typescript') {
164
+ const definesMain = /\bfunction\s+main\s*\(/.test(source);
165
+ const callsMain = /\bmain\s*\(/.test(source.replace(/function\s+main\s*\(/, ''));
166
+ const hasArgvParse = /process\.argv\[2\]/.test(source);
167
+ if (definesMain && !callsMain && !hasArgvParse) {
168
+ return `${source}
169
+
170
+ // --- Devtopia auto-wrapper: call main() with argv input ---
171
+ const __input = JSON.parse(process.argv[2] || '{}');
172
+ const __result = main(__input);
173
+ if (__result !== undefined) {
174
+ if (__result instanceof Promise) {
175
+ __result.then(r => console.log(JSON.stringify(r))).catch(e => {
176
+ console.error(e.message || e);
177
+ process.exit(1);
178
+ });
179
+ } else {
180
+ console.log(JSON.stringify(__result));
181
+ }
182
+ }
183
+ `;
184
+ }
185
+ }
186
+ if (language === 'python') {
187
+ const definesMain = /\bdef\s+main\s*\(/.test(source);
188
+ const callsMain = /\bmain\s*\(/.test(source.replace(/def\s+main\s*\(/, ''));
189
+ const hasArgvParse = /sys\.argv/.test(source);
190
+ if (definesMain && !callsMain && !hasArgvParse) {
191
+ return `${source}
192
+
193
+ # --- Devtopia auto-wrapper: call main() with argv input ---
194
+ import json, sys
195
+ __input = json.loads(sys.argv[1] if len(sys.argv) > 1 else '{}')
196
+ __result = main(__input)
197
+ if __result is not None:
198
+ print(json.dumps(__result))
199
+ `;
200
+ }
201
+ }
202
+ return source;
203
+ }
204
+ // ─── Composition Runtime Templates ───────────────────────────────────────────
205
+ const JS_RUNTIME = `// devtopia-runtime.js — Composition runtime for Devtopia tools
206
+ // Usage: const { devtopiaRun } = require('./devtopia-runtime');
207
+ // const result = devtopiaRun('tool-name', { input: 'data' });
208
+
209
+ const { execFileSync } = require('child_process');
210
+
211
+ function splitCommand(cmd) {
212
+ if (!cmd) return [];
213
+ const parts = cmd.match(/(?:[^\\s"']+|"[^"]*"|'[^']*')+/g) || [];
214
+ return parts.map((part) => part.replace(/^['"]|['"]$/g, ''));
215
+ }
216
+
217
+ function resolveCli() {
218
+ const cliNode = process.env.DEVTOPIA_CLI_NODE;
219
+ const cliPath = process.env.DEVTOPIA_CLI_PATH;
220
+ if (cliNode && cliPath) {
221
+ return { cmd: cliNode, args: [cliPath] };
222
+ }
223
+
224
+ const cliArgsRaw = process.env.DEVTOPIA_CLI_ARGS;
225
+ if (cliArgsRaw) {
226
+ try {
227
+ const parsed = JSON.parse(cliArgsRaw);
228
+ if (Array.isArray(parsed) && parsed.length > 0) {
229
+ return { cmd: parsed[0], args: parsed.slice(1) };
230
+ }
231
+ } catch {}
232
+ }
233
+
234
+ const cliCmd = process.env.DEVTOPIA_CLI || 'devtopia';
235
+ const parts = splitCommand(cliCmd);
236
+ return { cmd: parts[0] || cliCmd, args: parts.slice(1) };
237
+ }
238
+
239
+ function devtopiaRun(toolName, input) {
240
+ const inputStr = JSON.stringify(input || {});
241
+ const { cmd, args } = resolveCli();
242
+ try {
243
+ const stdout = execFileSync(
244
+ cmd,
245
+ [...args, 'run', toolName, inputStr, '--json', '--quiet'],
246
+ { encoding: 'utf-8', timeout: 30000, stdio: ['pipe', 'pipe', 'pipe'] }
247
+ );
248
+ const text = (stdout || '').trim();
249
+ const parsed = text ? JSON.parse(text) : null;
250
+ if (parsed && typeof parsed === 'object' && (parsed.error || parsed.ok === false)) {
251
+ throw new Error(parsed.error || 'Unknown error');
252
+ }
253
+ return parsed;
254
+ } catch (err) {
255
+ const stderr = err && err.stderr ? String(err.stderr).trim() : '';
256
+ const message = stderr || (err && err.message ? err.message : 'Unknown error');
257
+ throw new Error(\`devtopiaRun(\${toolName}) failed: \${message}\`);
258
+ }
259
+ }
260
+
261
+ module.exports = { devtopiaRun };
262
+ `;
263
+ const PY_RUNTIME = `# devtopia_runtime.py — Composition runtime for Devtopia tools
264
+ # Usage: from devtopia_runtime import devtopia_run
265
+ # result = devtopia_run('tool-name', {'input': 'data'})
266
+
267
+ import subprocess, json, os, shlex
268
+
269
+ def _resolve_cli():
270
+ cli_node = os.environ.get('DEVTOPIA_CLI_NODE')
271
+ cli_path = os.environ.get('DEVTOPIA_CLI_PATH')
272
+ if cli_node and cli_path:
273
+ return [cli_node, cli_path]
274
+ cli_args_raw = os.environ.get('DEVTOPIA_CLI_ARGS')
275
+ if cli_args_raw:
276
+ try:
277
+ parsed = json.loads(cli_args_raw)
278
+ if isinstance(parsed, list) and len(parsed) > 0:
279
+ return [str(x) for x in parsed]
280
+ except Exception:
281
+ pass
282
+ cli_cmd = os.environ.get('DEVTOPIA_CLI', 'devtopia')
283
+ return shlex.split(cli_cmd)
284
+
285
+ def devtopia_run(tool_name, input_data=None):
286
+ input_str = json.dumps(input_data or {})
287
+ try:
288
+ cmd = _resolve_cli()
289
+ result = subprocess.run(
290
+ (cmd + ['run', tool_name, input_str, '--json', '--quiet']),
291
+ capture_output=True, text=True, timeout=30
292
+ )
293
+ if result.returncode != 0:
294
+ raise RuntimeError(f"devtopia_run({tool_name}) failed: {result.stderr}")
295
+ output = (result.stdout or '').strip()
296
+ parsed = json.loads(output) if output else None
297
+ if isinstance(parsed, dict) and (parsed.get('error') is not None or parsed.get('ok') is False):
298
+ raise RuntimeError(parsed.get('error') or 'Unknown error')
299
+ return parsed
300
+ except subprocess.TimeoutExpired:
301
+ raise RuntimeError(f"devtopia_run({tool_name}) timed out")
302
+ `;
303
+ /**
304
+ * Write composition runtime helper into the working directory
305
+ */
306
+ function writeRuntimeHelper(workDir, language) {
307
+ if (language === 'javascript' || language === 'typescript') {
308
+ writeFileSync(join(workDir, 'devtopia-runtime.js'), JS_RUNTIME);
309
+ }
310
+ if (language === 'python') {
311
+ writeFileSync(join(workDir, 'devtopia_runtime.py'), PY_RUNTIME);
312
+ }
313
+ }
314
+ // ─── Validate tool execution (pre-submit) ────────────────────────────────────
315
+ /**
316
+ * Validate that a tool source actually runs and produces valid JSON output.
317
+ * Runs with {} as input, 10s timeout.
318
+ * Pass: process produces valid JSON stdout (even error JSON like {"error":"..."}).
319
+ * Fail: no stdout, non-JSON stdout with exit 0, or process hangs.
320
+ */
321
+ export async function validateExecution(source, language) {
322
+ const workDir = join(tmpdir(), `devtopia-validate-${randomUUID()}`);
323
+ mkdirSync(workDir, { recursive: true });
324
+ const ext = getToolExtension(language);
325
+ const toolFile = join(workDir, `tool${ext}`);
326
+ const wrappedSource = wrapSourceIfNeeded(source, language);
327
+ writeFileSync(toolFile, wrappedSource);
328
+ if (language === 'shebang') {
329
+ try {
330
+ chmodSync(toolFile, 0o755);
331
+ }
332
+ catch { }
333
+ }
334
+ // Also write runtime helper in case tool uses it
335
+ writeRuntimeHelper(workDir, language);
336
+ const { cmd, args } = getRunner(language, toolFile);
337
+ return new Promise((resolve) => {
338
+ const proc = spawn(cmd, [...args, '{}'], {
339
+ cwd: workDir,
340
+ env: { ...process.env },
341
+ timeout: 10000,
342
+ });
343
+ let stdout = '';
344
+ let stderr = '';
345
+ proc.stdout.on('data', (data) => { stdout += data.toString(); });
346
+ proc.stderr.on('data', (data) => { stderr += data.toString(); });
347
+ proc.on('close', (code) => {
348
+ try {
349
+ if (existsSync(workDir))
350
+ rmSync(workDir, { recursive: true, force: true });
351
+ }
352
+ catch { }
353
+ const trimmedOut = stdout.trim();
354
+ // No output at all = broken
355
+ if (trimmedOut.length === 0) {
356
+ resolve({ valid: false, stdout: '', stderr: stderr.trim(), exitCode: code });
357
+ return;
358
+ }
359
+ // Check if output is valid JSON
360
+ let isJson = false;
361
+ try {
362
+ JSON.parse(trimmedOut);
363
+ isJson = true;
364
+ }
365
+ catch { }
366
+ // Non-zero exit with JSON stderr/stdout = valid (error handling works)
367
+ if (code !== 0 && isJson) {
368
+ resolve({ valid: true, stdout: trimmedOut, stderr: stderr.trim(), exitCode: code });
369
+ return;
370
+ }
371
+ // Exit 0 with non-JSON output = invalid (must produce JSON)
372
+ if (code === 0 && !isJson) {
373
+ resolve({
374
+ valid: false,
375
+ stdout: trimmedOut,
376
+ stderr: 'Tool produced non-JSON output. All Devtopia tools must output valid JSON.',
377
+ exitCode: code,
378
+ });
379
+ return;
380
+ }
381
+ // Exit 0 with JSON = valid, non-zero with non-JSON stderr = broken
382
+ resolve({ valid: isJson, stdout: trimmedOut, stderr: stderr.trim(), exitCode: code });
383
+ });
384
+ proc.on('error', (err) => {
385
+ try {
386
+ if (existsSync(workDir))
387
+ rmSync(workDir, { recursive: true, force: true });
388
+ }
389
+ catch { }
390
+ resolve({ valid: false, stdout: '', stderr: err.message, exitCode: null });
391
+ });
392
+ });
393
+ }
394
+ // ─── Execute a tool locally ──────────────────────────────────────────────────
395
+ export async function executeTool(toolName, input, options = {}) {
37
396
  const startTime = Date.now();
397
+ const strictJson = options.strictJson === true;
38
398
  try {
39
- // Fetch the tool
40
399
  const tool = await fetchTool(toolName);
41
- // Fetch dependencies
42
- const deps = new Map();
43
- for (const depName of tool.dependencies) {
44
- const dep = await fetchTool(depName);
45
- deps.set(depName, dep);
46
- }
47
- // Create temp directory
48
400
  const workDir = join(tmpdir(), `devtopia-${randomUUID()}`);
49
401
  mkdirSync(workDir, { recursive: true });
50
- // Write tool source
51
- const { cmd, args, ext } = getInterpreter(tool.language);
402
+ const ext = getToolExtension(tool.language);
52
403
  const toolFile = join(workDir, `tool${ext}`);
53
- // Inject a simple devtopia runtime for dependencies
54
- let source = tool.source;
55
- if (deps.size > 0) {
56
- const depComment = `// Dependencies: ${[...deps.keys()].join(', ')}\n`;
57
- source = depComment + source;
58
- }
404
+ const source = wrapSourceIfNeeded(tool.source, tool.language);
59
405
  writeFileSync(toolFile, source);
60
- // Execute and wait for completion
406
+ if (tool.language === 'shebang') {
407
+ try {
408
+ chmodSync(toolFile, 0o755);
409
+ }
410
+ catch { }
411
+ }
412
+ // Always write runtime helper so tools can compose
413
+ writeRuntimeHelper(workDir, tool.language);
414
+ const { cmd, args } = getRunner(tool.language, toolFile);
61
415
  const result = await new Promise((resolve) => {
62
416
  const inputStr = JSON.stringify(input);
63
- const proc = spawn(cmd, [...args, toolFile, inputStr], {
417
+ const cliArgs = resolveCliArgsForRuntime();
418
+ const env = {
419
+ ...process.env,
420
+ INPUT: inputStr,
421
+ ...(cliArgs ? { DEVTOPIA_CLI_ARGS: JSON.stringify(cliArgs) } : {}),
422
+ };
423
+ const proc = spawn(cmd, [...args, inputStr], {
64
424
  cwd: workDir,
65
- env: {
66
- ...process.env,
67
- INPUT: inputStr,
68
- },
69
- timeout: 30000, // 30 second timeout
425
+ env,
426
+ timeout: 30000,
70
427
  });
71
428
  let stdout = '';
72
429
  let stderr = '';
73
- proc.stdout.on('data', (data) => {
74
- stdout += data.toString();
430
+ proc.stdout.on('data', (data) => { stdout += data.toString(); });
431
+ proc.stderr.on('data', (data) => { stderr += data.toString(); });
432
+ proc.on('close', (code) => {
433
+ const durationMs = Date.now() - startTime;
434
+ const trimmedOut = stdout.trim();
435
+ const trimmedErr = stderr.trim();
436
+ const tryParseJson = (text) => {
437
+ if (!text)
438
+ return null;
439
+ try {
440
+ return JSON.parse(text);
441
+ }
442
+ catch {
443
+ return null;
444
+ }
445
+ };
446
+ if (code !== 0) {
447
+ if (strictJson) {
448
+ const parsed = tryParseJson(trimmedOut) ?? tryParseJson(trimmedErr);
449
+ resolve({
450
+ success: false,
451
+ output: parsed,
452
+ error: (parsed && typeof parsed === 'object' && parsed.error) ? String(parsed.error) : (trimmedErr || `Process exited with code ${code}`),
453
+ durationMs,
454
+ });
455
+ return;
456
+ }
457
+ resolve({ success: false, output: null, error: trimmedErr || `Process exited with code ${code}`, durationMs });
458
+ return;
459
+ }
460
+ if (trimmedOut.length === 0) {
461
+ if (strictJson) {
462
+ resolve({ success: false, output: null, error: 'Tool produced no output', durationMs });
463
+ }
464
+ else {
465
+ resolve({ success: true, output: '', durationMs });
466
+ }
467
+ return;
468
+ }
469
+ const parsed = tryParseJson(trimmedOut);
470
+ if (parsed === null) {
471
+ if (strictJson) {
472
+ resolve({ success: false, output: null, error: 'Tool produced non-JSON output', durationMs });
473
+ }
474
+ else {
475
+ resolve({ success: true, output: trimmedOut, durationMs });
476
+ }
477
+ return;
478
+ }
479
+ resolve({ success: true, output: parsed, durationMs });
75
480
  });
76
- proc.stderr.on('data', (data) => {
77
- stderr += data.toString();
481
+ proc.on('error', (err) => {
482
+ resolve({ success: false, output: null, error: err.message, durationMs: Date.now() - startTime });
483
+ });
484
+ });
485
+ if (existsSync(workDir))
486
+ rmSync(workDir, { recursive: true, force: true });
487
+ return result;
488
+ }
489
+ catch (err) {
490
+ return { success: false, output: null, error: err.message, durationMs: Date.now() - startTime };
491
+ }
492
+ }
493
+ // ─── Execute a local tool file (with runtime injection) ─────────────────────
494
+ export async function executeLocalFile(filePath, input, options = {}) {
495
+ const startTime = Date.now();
496
+ const strictJson = options.strictJson === true;
497
+ try {
498
+ if (!existsSync(filePath)) {
499
+ return { success: false, output: null, error: `File not found: ${filePath}`, durationMs: Date.now() - startTime };
500
+ }
501
+ const source = readFileSync(filePath, 'utf-8');
502
+ const language = detectLanguageFromFile(filePath, source);
503
+ if (!language) {
504
+ return { success: false, output: null, error: `Unsupported file type: ${filePath}`, durationMs: Date.now() - startTime };
505
+ }
506
+ const workDir = join(tmpdir(), `devtopia-local-${randomUUID()}`);
507
+ mkdirSync(workDir, { recursive: true });
508
+ const ext = getToolExtension(language);
509
+ const toolFile = join(workDir, `tool${ext}`);
510
+ const wrappedSource = wrapSourceIfNeeded(source, language);
511
+ writeFileSync(toolFile, wrappedSource);
512
+ if (language === 'shebang') {
513
+ try {
514
+ chmodSync(toolFile, 0o755);
515
+ }
516
+ catch { }
517
+ }
518
+ writeRuntimeHelper(workDir, language);
519
+ const { cmd, args } = getRunner(language, toolFile);
520
+ const result = await new Promise((resolve) => {
521
+ const inputStr = JSON.stringify(input);
522
+ const cliArgs = resolveCliArgsForRuntime();
523
+ const env = {
524
+ ...process.env,
525
+ INPUT: inputStr,
526
+ ...(cliArgs ? { DEVTOPIA_CLI_ARGS: JSON.stringify(cliArgs) } : {}),
527
+ };
528
+ const proc = spawn(cmd, [...args, inputStr], {
529
+ cwd: workDir,
530
+ env,
531
+ timeout: 30000,
78
532
  });
533
+ let stdout = '';
534
+ let stderr = '';
535
+ proc.stdout.on('data', (data) => { stdout += data.toString(); });
536
+ proc.stderr.on('data', (data) => { stderr += data.toString(); });
79
537
  proc.on('close', (code) => {
80
538
  const durationMs = Date.now() - startTime;
539
+ const trimmedOut = stdout.trim();
540
+ const trimmedErr = stderr.trim();
541
+ const tryParseJson = (text) => {
542
+ if (!text)
543
+ return null;
544
+ try {
545
+ return JSON.parse(text);
546
+ }
547
+ catch {
548
+ return null;
549
+ }
550
+ };
81
551
  if (code !== 0) {
82
- resolve({
83
- success: false,
84
- output: null,
85
- error: stderr || `Process exited with code ${code}`,
86
- durationMs,
87
- });
552
+ if (strictJson) {
553
+ const parsed = tryParseJson(trimmedOut) ?? tryParseJson(trimmedErr);
554
+ resolve({
555
+ success: false,
556
+ output: parsed,
557
+ error: (parsed && typeof parsed === 'object' && parsed.error) ? String(parsed.error) : (trimmedErr || `Process exited with code ${code}`),
558
+ durationMs,
559
+ });
560
+ return;
561
+ }
562
+ resolve({ success: false, output: null, error: trimmedErr || `Process exited with code ${code}`, durationMs });
88
563
  return;
89
564
  }
90
- try {
91
- // Parse JSON output
92
- const output = JSON.parse(stdout.trim());
93
- resolve({
94
- success: true,
95
- output,
96
- durationMs,
97
- });
565
+ if (trimmedOut.length === 0) {
566
+ if (strictJson) {
567
+ resolve({ success: false, output: null, error: 'Tool produced no output', durationMs });
568
+ }
569
+ else {
570
+ resolve({ success: true, output: '', durationMs });
571
+ }
572
+ return;
98
573
  }
99
- catch {
100
- // Return raw output if not JSON
101
- resolve({
102
- success: true,
103
- output: stdout.trim(),
104
- durationMs,
105
- });
574
+ const parsed = tryParseJson(trimmedOut);
575
+ if (parsed === null) {
576
+ if (strictJson) {
577
+ resolve({ success: false, output: null, error: 'Tool produced non-JSON output', durationMs });
578
+ }
579
+ else {
580
+ resolve({ success: true, output: trimmedOut, durationMs });
581
+ }
582
+ return;
106
583
  }
584
+ resolve({ success: true, output: parsed, durationMs });
107
585
  });
108
586
  proc.on('error', (err) => {
109
- resolve({
110
- success: false,
111
- output: null,
112
- error: err.message,
113
- durationMs: Date.now() - startTime,
114
- });
587
+ resolve({ success: false, output: null, error: err.message, durationMs: Date.now() - startTime });
115
588
  });
116
589
  });
117
- // Cleanup temp directory after execution completes
118
- if (existsSync(workDir)) {
590
+ if (existsSync(workDir))
119
591
  rmSync(workDir, { recursive: true, force: true });
120
- }
121
592
  return result;
122
593
  }
123
594
  catch (err) {
124
- const durationMs = Date.now() - startTime;
125
- return {
126
- success: false,
127
- output: null,
128
- error: err.message,
129
- durationMs,
130
- };
595
+ return { success: false, output: null, error: err.message, durationMs: Date.now() - startTime };
131
596
  }
132
597
  }