cli4ai 1.2.4 → 1.2.6

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 (39) hide show
  1. package/dist/cli.js +31 -0
  2. package/dist/commands/add.js +5 -1
  3. package/dist/commands/browse.js +65 -52
  4. package/dist/commands/download.d.ts +7 -0
  5. package/dist/commands/download.js +36 -0
  6. package/dist/commands/routines.js +10 -3
  7. package/dist/commands/scheduler.js +3 -0
  8. package/dist/commands/secrets.js +37 -24
  9. package/dist/commands/serve.d.ts +4 -0
  10. package/dist/commands/serve.js +9 -0
  11. package/dist/commands/update.js +6 -2
  12. package/dist/core/execute.d.ts +4 -0
  13. package/dist/core/execute.js +37 -12
  14. package/dist/core/remote-client.d.ts +8 -0
  15. package/dist/core/remote-client.js +67 -0
  16. package/dist/core/routine-engine.d.ts +90 -6
  17. package/dist/core/routine-engine.js +769 -221
  18. package/dist/core/routines.d.ts +5 -0
  19. package/dist/core/routines.js +20 -0
  20. package/dist/core/scheduler.d.ts +19 -0
  21. package/dist/core/scheduler.js +79 -1
  22. package/dist/dashboard/api/endpoints.d.ts +14 -0
  23. package/dist/dashboard/api/endpoints.js +562 -0
  24. package/dist/dashboard/api/websocket.d.ts +133 -0
  25. package/dist/dashboard/api/websocket.js +278 -0
  26. package/dist/dashboard/db/index.d.ts +33 -0
  27. package/dist/dashboard/db/index.js +69 -0
  28. package/dist/dashboard/db/runs.d.ts +170 -0
  29. package/dist/dashboard/db/runs.js +475 -0
  30. package/dist/dashboard/db/schema.d.ts +64 -0
  31. package/dist/dashboard/db/schema.js +157 -0
  32. package/dist/mcp/adapter.js +3 -0
  33. package/dist/mcp/server.js +3 -1
  34. package/dist/server/service.d.ts +8 -0
  35. package/dist/server/service.js +192 -6
  36. package/package.json +11 -3
  37. package/src/dashboard/public/assets/index-DN1hIAMO.css +1 -0
  38. package/src/dashboard/public/assets/index-pZeAAQwj.js +331 -0
  39. package/src/dashboard/public/index.html +14 -0
package/dist/cli.js CHANGED
@@ -21,6 +21,7 @@ import { routinesListCommand, routinesRunCommand, routinesShowCommand, routinesC
21
21
  import { schedulerStartCommand, schedulerStopCommand, schedulerStatusCommand, schedulerLogsCommand, schedulerHistoryCommand, schedulerRunCommand } from './commands/scheduler.js';
22
22
  import { remotesListCommand, remotesAddCommand, remotesUpdateCommand, remotesRemoveCommand, remotesShowCommand, remotesTestCommand, remotesPackagesCommand } from './commands/remotes.js';
23
23
  import { serveCommand } from './commands/serve.js';
24
+ import { downloadCommand } from './commands/download.js';
24
25
  export function createProgram() {
25
26
  const program = new Command()
26
27
  .name('cli4ai')
@@ -276,6 +277,8 @@ Remote Execution:
276
277
  .option('-k, --api-key <key>', 'API key for authentication')
277
278
  .option('--scope <levels>', 'Comma-separated allowed scopes (read,write,full)')
278
279
  .option('--cwd <dir>', 'Working directory for execution')
280
+ .option('--ui', 'Enable the dashboard UI at /ui')
281
+ .option('--allow-path <path>', 'Allow file downloads from path (can be used multiple times)', (val, prev) => prev.concat([val]), [])
279
282
  .addHelpText('after', `
280
283
 
281
284
  Examples:
@@ -283,13 +286,26 @@ Examples:
283
286
  cli4ai serve --port 8080 # Start on port 8080
284
287
  cli4ai serve --api-key mysecretkey # Require API key
285
288
  cli4ai serve --scope read,write # Restrict to read/write operations
289
+ cli4ai serve --ui # Enable dashboard at /ui
290
+ cli4ai serve --allow-path /data # Allow file downloads from /data
286
291
 
287
292
  The service exposes these endpoints:
288
293
  GET /health - Service health check
289
294
  GET /packages - List available packages
290
295
  GET /packages/:name - Get package info
296
+ GET /files?path=<path> - Download file (restricted to allowed paths)
291
297
  POST /run - Execute a tool
292
298
  POST /routines/:name/run - Run a routine
299
+
300
+ By default, file downloads are allowed from: cwd, tmpdir, ~/Downloads.
301
+ Use --allow-path to add additional directories.
302
+
303
+ With --ui flag, additional endpoints are available:
304
+ GET /ui - Dashboard web interface
305
+ GET /api/runs - Run history
306
+ GET /api/routines - Routine management
307
+ GET /api/scheduler - Scheduler status
308
+ GET /api/metrics - Metrics and stats
293
309
  `)
294
310
  .action(withErrorHandling(serveCommand));
295
311
  const remotes = program
@@ -331,5 +347,20 @@ The service exposes these endpoints:
331
347
  .command('packages <name>')
332
348
  .description('List packages on a remote')
333
349
  .action(withErrorHandling(remotesPackagesCommand));
350
+ program
351
+ .command('download <remote> <path>')
352
+ .description('Download a file from a remote service')
353
+ .option('-o, --output <path>', 'Local output path (default: current directory)')
354
+ .addHelpText('after', `
355
+
356
+ Examples:
357
+ cli4ai download myserver /path/to/file.jpg
358
+ cli4ai download myserver /tmp/output.pdf -o ./local.pdf
359
+ cli4ai download mac ~/Downloads/image.png -o ./image.png
360
+
361
+ The remote must be configured via 'cli4ai remotes add' first.
362
+ File access is restricted to allowed paths on the server.
363
+ `)
364
+ .action(withErrorHandling(downloadCommand));
334
365
  return program;
335
366
  }
@@ -59,11 +59,15 @@ async function confirm(message) {
59
59
  input: process.stdin,
60
60
  output: process.stderr
61
61
  });
62
- return new Promise((resolve) => {
62
+ return new Promise((resolve, reject) => {
63
63
  rl.question(`${message} [y/N] `, (answer) => {
64
64
  rl.close();
65
65
  resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
66
66
  });
67
+ rl.on('error', (err) => {
68
+ rl.close();
69
+ reject(err);
70
+ });
67
71
  });
68
72
  }
69
73
  /**
@@ -190,61 +190,74 @@ async function multiSelect(items) {
190
190
  log('');
191
191
  };
192
192
  return new Promise((resolve) => {
193
- process.stdin.setRawMode(true);
194
- process.stdin.resume();
195
- process.stdin.setEncoding('utf8');
196
- render();
197
- const onKeypress = (key) => {
198
- // Handle arrow keys (escape sequences)
199
- if (key === '\x1B[A' || key === 'k') {
200
- // Up arrow or k
201
- cursor = Math.max(0, cursor - 1);
202
- render();
203
- }
204
- else if (key === '\x1B[B' || key === 'j') {
205
- // Down arrow or j
206
- cursor = Math.min(items.length - 1, cursor + 1);
207
- render();
208
- }
209
- else if (key === ' ') {
210
- // Space - toggle selection
211
- if (selected.has(cursor)) {
212
- selected.delete(cursor);
193
+ try {
194
+ process.stdin.setRawMode(true);
195
+ process.stdin.resume();
196
+ process.stdin.setEncoding('utf8');
197
+ render();
198
+ const onKeypress = (key) => {
199
+ // Handle arrow keys (escape sequences)
200
+ if (key === '\x1B[A' || key === 'k') {
201
+ // Up arrow or k
202
+ cursor = Math.max(0, cursor - 1);
203
+ render();
213
204
  }
214
- else {
215
- selected.add(cursor);
205
+ else if (key === '\x1B[B' || key === 'j') {
206
+ // Down arrow or j
207
+ cursor = Math.min(items.length - 1, cursor + 1);
208
+ render();
209
+ }
210
+ else if (key === ' ') {
211
+ // Space - toggle selection
212
+ if (selected.has(cursor)) {
213
+ selected.delete(cursor);
214
+ }
215
+ else {
216
+ selected.add(cursor);
217
+ }
218
+ render();
219
+ }
220
+ else if (key === '\r' || key === '\n') {
221
+ // Enter - confirm
222
+ process.stdin.setRawMode(false);
223
+ process.stdin.removeListener('data', onKeypress);
224
+ process.stdin.pause();
225
+ process.stderr.write('\x1B[2J\x1B[H'); // Clear screen
226
+ const selectedPackages = Array.from(selected).map(i => items[i].name);
227
+ resolve(selectedPackages);
228
+ }
229
+ else if (key === 'q' || key === '\x1B' || key === '\x03') {
230
+ // q, Escape, or Ctrl+C - quit
231
+ process.stdin.setRawMode(false);
232
+ process.stdin.removeListener('data', onKeypress);
233
+ process.stdin.pause();
234
+ process.stderr.write('\x1B[2J\x1B[H'); // Clear screen
235
+ resolve([]);
236
+ }
237
+ else if (key === 'a') {
238
+ // Select all
239
+ items.forEach((_, i) => selected.add(i));
240
+ render();
241
+ }
242
+ else if (key === 'n') {
243
+ // Select none
244
+ selected.clear();
245
+ render();
246
+ }
247
+ };
248
+ process.stdin.on('data', onKeypress);
249
+ }
250
+ finally {
251
+ // Ensure raw mode is reset even if an error occurs
252
+ if (process.stdin.isTTY && process.stdin.setRawMode) {
253
+ try {
254
+ process.stdin.setRawMode(false);
255
+ }
256
+ catch {
257
+ // Ignore errors when resetting raw mode
216
258
  }
217
- render();
218
- }
219
- else if (key === '\r' || key === '\n') {
220
- // Enter - confirm
221
- process.stdin.setRawMode(false);
222
- process.stdin.removeListener('data', onKeypress);
223
- process.stdin.pause();
224
- process.stderr.write('\x1B[2J\x1B[H'); // Clear screen
225
- const selectedPackages = Array.from(selected).map(i => items[i].name);
226
- resolve(selectedPackages);
227
- }
228
- else if (key === 'q' || key === '\x1B' || key === '\x03') {
229
- // q, Escape, or Ctrl+C - quit
230
- process.stdin.setRawMode(false);
231
- process.stdin.removeListener('data', onKeypress);
232
- process.stdin.pause();
233
- process.stderr.write('\x1B[2J\x1B[H'); // Clear screen
234
- resolve([]);
235
- }
236
- else if (key === 'a') {
237
- // Select all
238
- items.forEach((_, i) => selected.add(i));
239
- render();
240
- }
241
- else if (key === 'n') {
242
- // Select none
243
- selected.clear();
244
- render();
245
259
  }
246
- };
247
- process.stdin.on('data', onKeypress);
260
+ }
248
261
  });
249
262
  }
250
263
  /**
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Download command - Download files from a remote cli4ai service.
3
+ */
4
+ export interface DownloadCommandOptions {
5
+ output?: string;
6
+ }
7
+ export declare function downloadCommand(remoteName: string, filePath: string, options: DownloadCommandOptions): Promise<void>;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Download command - Download files from a remote cli4ai service.
3
+ */
4
+ import { basename, resolve } from 'path';
5
+ import { output, outputError, log } from '../lib/cli.js';
6
+ import { remoteDownloadFile, RemoteConnectionError, RemoteApiError } from '../core/remote-client.js';
7
+ export async function downloadCommand(remoteName, filePath, options) {
8
+ // Determine output path
9
+ const outputPath = options.output
10
+ ? resolve(options.output)
11
+ : resolve(process.cwd(), basename(filePath));
12
+ log(`Downloading from ${remoteName}: ${filePath}`);
13
+ log(`Saving to: ${outputPath}`);
14
+ try {
15
+ const result = await remoteDownloadFile(remoteName, filePath, outputPath);
16
+ output({
17
+ success: true,
18
+ remote: remoteName,
19
+ remotePath: filePath,
20
+ localPath: result.path,
21
+ size: result.size
22
+ });
23
+ }
24
+ catch (err) {
25
+ if (err instanceof RemoteConnectionError) {
26
+ outputError('CONNECTION_ERROR', err.message, {
27
+ remote: remoteName,
28
+ url: err.url
29
+ });
30
+ }
31
+ if (err instanceof RemoteApiError) {
32
+ outputError(err.code, err.message, err.details);
33
+ }
34
+ outputError('DOWNLOAD_ERROR', err instanceof Error ? err.message : String(err));
35
+ }
36
+ }
@@ -4,8 +4,10 @@
4
4
  import { spawn, spawnSync } from 'child_process';
5
5
  import { readFileSync, writeFileSync, existsSync, chmodSync, rmSync } from 'fs';
6
6
  import { resolve } from 'path';
7
+ import { platform } from 'os';
7
8
  import { stringify as stringifyYaml } from 'yaml';
8
9
  import { output, outputError, log } from '../lib/cli.js';
10
+ const isWindows = platform() === 'win32';
9
11
  import { ensureCli4aiHome, ensureLocalDir, ROUTINES_DIR, LOCAL_ROUTINES_DIR } from '../core/config.js';
10
12
  import { getGlobalRoutines, getLocalRoutines, resolveRoutine, validateRoutineName } from '../core/routines.js';
11
13
  import { loadRoutineDefinition, dryRunRoutine, runRoutine, RoutineParseError, RoutineValidationError, RoutineTemplateError } from '../core/routine-engine.js';
@@ -30,7 +32,7 @@ function parseVars(vars) {
30
32
  return result;
31
33
  for (const entry of vars) {
32
34
  const eq = entry.indexOf('=');
33
- if (eq <= 0) {
35
+ if (eq < 0) {
34
36
  outputError('INVALID_INPUT', `Invalid --var (expected KEY=value): ${entry}`);
35
37
  }
36
38
  const key = entry.slice(0, eq).trim();
@@ -256,6 +258,7 @@ export async function routinesRunCommand(name, args, options) {
256
258
  const child = spawn('bash', [routine.path, ...args], {
257
259
  cwd: process.cwd(),
258
260
  stdio: isTTY ? 'inherit' : ['pipe', 'pipe', 'pipe'],
261
+ shell: isWindows,
259
262
  env: {
260
263
  ...process.env,
261
264
  ...varEnv,
@@ -272,9 +275,12 @@ export async function routinesRunCommand(name, args, options) {
272
275
  }
273
276
  // Non-TTY: capture and emit single JSON summary.
274
277
  const stdoutPromise = child.stdout ? collectStream(child.stdout) : Promise.resolve('');
275
- const stderrPromise = child.stderr ? collectStream(child.stderr) : Promise.resolve('');
278
+ // Stream stderr to process.stderr while collecting it
279
+ const stderrChunks = [];
276
280
  if (child.stderr) {
277
281
  child.stderr.on('data', (chunk) => {
282
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
283
+ stderrChunks.push(buf);
278
284
  process.stderr.write(chunk);
279
285
  });
280
286
  }
@@ -282,7 +288,8 @@ export async function routinesRunCommand(name, args, options) {
282
288
  child.on('close', (code) => resolve(code ?? 0));
283
289
  child.on('error', (err) => outputError('API_ERROR', `Failed to execute routine: ${err.message}`));
284
290
  });
285
- const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]);
291
+ const stdout = await stdoutPromise;
292
+ const stderr = Buffer.concat(stderrChunks).toString('utf-8');
286
293
  output({
287
294
  routine: routine.name,
288
295
  kind: routine.kind,
@@ -12,7 +12,9 @@ import { spawn } from 'child_process';
12
12
  import { readFileSync, existsSync } from 'fs';
13
13
  import { resolve, dirname } from 'path';
14
14
  import { fileURLToPath } from 'url';
15
+ import { platform } from 'os';
15
16
  import { output, outputError, log } from '../lib/cli.js';
17
+ const isWindows = platform() === 'win32';
16
18
  import { isDaemonRunning, getDaemonPid, removeDaemonPid, loadSchedulerState, getRunHistory, Scheduler, SCHEDULER_LOG_FILE, SCHEDULER_PID_FILE } from '../core/scheduler.js';
17
19
  import { getScheduledRoutines } from '../core/routines.js';
18
20
  export async function schedulerStartCommand(options) {
@@ -55,6 +57,7 @@ export async function schedulerStartCommand(options) {
55
57
  const child = spawn('npx', ['tsx', daemonScript, '--project-dir', process.cwd()], {
56
58
  detached: true,
57
59
  stdio: 'ignore',
60
+ shell: isWindows,
58
61
  env: {
59
62
  ...process.env,
60
63
  CLI4AI_DAEMON: 'true'
@@ -33,32 +33,45 @@ async function prompt(message, hidden = false) {
33
33
  // Hide input for secrets
34
34
  process.stderr.write(message);
35
35
  let value = '';
36
- process.stdin.setRawMode(true);
37
- process.stdin.resume();
38
- process.stdin.setEncoding('utf8');
39
- const onData = (char) => {
40
- if (char === '\n' || char === '\r') {
41
- process.stdin.setRawMode(false);
42
- process.stdin.removeListener('data', onData);
43
- process.stderr.write('\n');
44
- rl.close();
45
- resolve(value);
46
- }
47
- else if (char === '\u0003') {
48
- // Ctrl+C
49
- process.exit(1);
50
- }
51
- else if (char === '\u007F') {
52
- // Backspace
53
- if (value.length > 0) {
54
- value = value.slice(0, -1);
36
+ try {
37
+ process.stdin.setRawMode(true);
38
+ process.stdin.resume();
39
+ process.stdin.setEncoding('utf8');
40
+ const onData = (char) => {
41
+ if (char === '\n' || char === '\r') {
42
+ process.stdin.setRawMode(false);
43
+ process.stdin.removeListener('data', onData);
44
+ process.stderr.write('\n');
45
+ rl.close();
46
+ resolve(value);
47
+ }
48
+ else if (char === '\u0003') {
49
+ // Ctrl+C
50
+ process.exit(1);
51
+ }
52
+ else if (char === '\u007F') {
53
+ // Backspace
54
+ if (value.length > 0) {
55
+ value = value.slice(0, -1);
56
+ }
57
+ }
58
+ else {
59
+ value += char;
60
+ }
61
+ };
62
+ process.stdin.on('data', onData);
63
+ }
64
+ finally {
65
+ // Ensure raw mode is reset even if an error occurs
66
+ if (process.stdin.isTTY && process.stdin.setRawMode) {
67
+ try {
68
+ process.stdin.setRawMode(false);
69
+ }
70
+ catch {
71
+ // Ignore errors when resetting raw mode
55
72
  }
56
73
  }
57
- else {
58
- value += char;
59
- }
60
- };
61
- process.stdin.on('data', onData);
74
+ }
62
75
  }
63
76
  else {
64
77
  rl.question(message, (answer) => {
@@ -9,5 +9,9 @@ export interface ServeCommandOptions {
9
9
  apiKey?: string;
10
10
  scope?: string;
11
11
  cwd?: string;
12
+ /** Enable the dashboard UI at /ui */
13
+ ui?: boolean;
14
+ /** Additional paths allowed for file downloads */
15
+ allowPath?: string[];
12
16
  }
13
17
  export declare function serveCommand(options: ServeCommandOptions): Promise<void>;
@@ -36,6 +36,15 @@ export async function serveCommand(options) {
36
36
  }
37
37
  serviceOptions.allowedScopes = scopes;
38
38
  }
39
+ // Enable dashboard UI
40
+ if (options.ui) {
41
+ serviceOptions.enableDashboard = true;
42
+ }
43
+ // Additional allowed paths for file downloads (adds to defaults)
44
+ if (options.allowPath && options.allowPath.length > 0) {
45
+ // Pass extra paths - service will merge with defaults
46
+ serviceOptions.allowedPaths = options.allowPath;
47
+ }
39
48
  try {
40
49
  await startService(serviceOptions);
41
50
  output({ status: 'stopped' });
@@ -2,7 +2,9 @@
2
2
  * cli4ai update - Update installed packages and cli4ai itself
3
3
  */
4
4
  import { spawn } from 'child_process';
5
+ import { platform } from 'os';
5
6
  import { log } from '../lib/cli.js';
7
+ const isWindows = platform() === 'win32';
6
8
  import { getNpmGlobalPackages, getGlobalPackages } from '../core/config.js';
7
9
  // ANSI colors
8
10
  const RESET = '\x1B[0m';
@@ -50,7 +52,8 @@ async function updateCli4aiPackage(packageName) {
50
52
  return new Promise((resolve) => {
51
53
  // Use cli4ai add -g to reinstall the package (same location as original install)
52
54
  const proc = spawn('cli4ai', ['add', '-g', packageName, '-y'], {
53
- stdio: 'pipe'
55
+ stdio: 'pipe',
56
+ shell: isWindows
54
57
  });
55
58
  proc.on('close', (code) => {
56
59
  resolve(code === 0);
@@ -66,7 +69,8 @@ async function updateCli4aiPackage(packageName) {
66
69
  async function updateNpmPackage(packageName) {
67
70
  return new Promise((resolve) => {
68
71
  const proc = spawn('npm', ['install', '-g', `${packageName}@latest`], {
69
- stdio: 'pipe'
72
+ stdio: 'pipe',
73
+ shell: isWindows
70
74
  });
71
75
  proc.on('close', (code) => {
72
76
  resolve(code === 0);
@@ -27,6 +27,10 @@ export interface ExecuteToolOptions {
27
27
  scope?: ScopeLevel;
28
28
  /** Run in sandboxed environment with restricted file system access */
29
29
  sandbox?: boolean;
30
+ /** Callback for each stdout line (for live streaming) */
31
+ onStdoutLine?: (line: string) => void;
32
+ /** Callback for each stderr line (for live streaming) */
33
+ onStderrLine?: (line: string) => void;
30
34
  }
31
35
  export interface ExecuteToolResult {
32
36
  exitCode: number;
@@ -399,8 +399,8 @@ export async function executeTool(options) {
399
399
  }
400
400
  });
401
401
  const exitCode = await new Promise((resolve, reject) => {
402
- proc.on('close', (code) => resolve(code ?? 0));
403
- proc.on('error', (err) => {
402
+ proc.once('close', (code) => resolve(code ?? 0));
403
+ proc.once('error', (err) => {
404
404
  reject(new ExecuteToolError('API_ERROR', `Failed to execute: ${err.message}`));
405
405
  });
406
406
  });
@@ -437,13 +437,33 @@ export async function executeTool(options) {
437
437
  // Don’t block on stdin if nothing is provided
438
438
  proc.stdin.end();
439
439
  }
440
- if (teeStderr && proc.stderr) {
441
- proc.stderr.on('data', (chunk) => {
442
- process.stderr.write(chunk);
440
+ // Set up line-by-line streaming for callbacks
441
+ const stdoutLines = [];
442
+ const stderrLines = [];
443
+ const readlineInterfaces = [];
444
+ if (proc.stdout) {
445
+ const rl = createInterface({ input: proc.stdout, crlfDelay: Infinity });
446
+ readlineInterfaces.push(rl);
447
+ rl.on('line', (line) => {
448
+ stdoutLines.push(line);
449
+ if (options.onStdoutLine) {
450
+ options.onStdoutLine(line);
451
+ }
452
+ });
453
+ }
454
+ if (proc.stderr) {
455
+ const rl = createInterface({ input: proc.stderr, crlfDelay: Infinity });
456
+ readlineInterfaces.push(rl);
457
+ rl.on('line', (line) => {
458
+ stderrLines.push(line);
459
+ if (teeStderr) {
460
+ process.stderr.write(line + '\n');
461
+ }
462
+ if (options.onStderrLine) {
463
+ options.onStderrLine(line);
464
+ }
443
465
  });
444
466
  }
445
- const stdoutPromise = proc.stdout ? collectStream(proc.stdout) : Promise.resolve('');
446
- const stderrPromise = proc.stderr ? collectStream(proc.stderr) : Promise.resolve('');
447
467
  let timeout;
448
468
  if (options.timeoutMs && options.timeoutMs > 0) {
449
469
  timeout = setTimeout(() => {
@@ -460,18 +480,23 @@ export async function executeTool(options) {
460
480
  }, options.timeoutMs);
461
481
  }
462
482
  const exitCode = await new Promise((resolve, reject) => {
463
- proc.on('close', (code) => resolve(code ?? 0));
464
- proc.on('error', (err) => reject(new ExecuteToolError('API_ERROR', `Failed to execute: ${err.message}`)));
483
+ proc.once('close', (code) => resolve(code ?? 0));
484
+ proc.once('error', (err) => reject(new ExecuteToolError('API_ERROR', `Failed to execute: ${err.message}`)));
465
485
  }).finally(() => {
466
486
  if (timeout)
467
487
  clearTimeout(timeout);
488
+ // Close all readline interfaces
489
+ for (const rl of readlineInterfaces) {
490
+ rl.close();
491
+ }
468
492
  });
469
- const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]);
493
+ // Small delay to ensure all readline events are processed
494
+ await new Promise(r => setTimeout(r, 10));
470
495
  return {
471
496
  exitCode,
472
497
  durationMs: Date.now() - startTime,
473
- stdout,
474
- stderr,
498
+ stdout: stdoutLines.length > 0 ? stdoutLines.join('\n') + '\n' : '',
499
+ stderr: stderrLines.length > 0 ? stderrLines.join('\n') + '\n' : '',
475
500
  packagePath: pkg.path,
476
501
  entryPath,
477
502
  runtime
@@ -88,6 +88,14 @@ export declare function remoteRunTool(remoteName: string, options: RemoteRunOpti
88
88
  * Run a routine on a remote
89
89
  */
90
90
  export declare function remoteRunRoutine(remoteName: string, routineName: string, vars?: Record<string, string>): Promise<RoutineRunSummary>;
91
+ /**
92
+ * Download a file from a remote
93
+ */
94
+ export declare function remoteDownloadFile(remoteName: string, filePath: string, outputPath: string): Promise<{
95
+ success: boolean;
96
+ size: number;
97
+ path: string;
98
+ }>;
91
99
  /**
92
100
  * Test connection to a remote
93
101
  */
@@ -214,6 +214,73 @@ export async function remoteRunRoutine(remoteName, routineName, vars) {
214
214
  throw new RemoteConnectionError(remoteName, remote.url, err instanceof Error ? err.message : String(err));
215
215
  }
216
216
  }
217
+ /**
218
+ * Download a file from a remote
219
+ */
220
+ export async function remoteDownloadFile(remoteName, filePath, outputPath) {
221
+ const remote = getRemoteOrThrow(remoteName);
222
+ const url = new URL('/files', remote.url);
223
+ url.searchParams.set('path', filePath);
224
+ const { createWriteStream, promises: fs } = await import('fs');
225
+ const { pipeline } = await import('stream/promises');
226
+ return new Promise((resolve, reject) => {
227
+ const isHttps = url.protocol === 'https:';
228
+ const requestFn = isHttps ? httpsRequest : httpRequest;
229
+ const headers = {};
230
+ if (remote.apiKey) {
231
+ headers['X-API-Key'] = remote.apiKey;
232
+ }
233
+ const options = {
234
+ method: 'GET',
235
+ hostname: url.hostname,
236
+ port: url.port || (isHttps ? 443 : 80),
237
+ path: url.pathname + url.search,
238
+ headers,
239
+ timeout: 300000 // 5 min timeout for file downloads
240
+ };
241
+ const req = requestFn(options, async (res) => {
242
+ if (res.statusCode === 200) {
243
+ try {
244
+ const writeStream = createWriteStream(outputPath);
245
+ await pipeline(res, writeStream);
246
+ const stats = await fs.stat(outputPath);
247
+ updateRemoteLastConnected(remoteName);
248
+ resolve({
249
+ success: true,
250
+ size: stats.size,
251
+ path: outputPath
252
+ });
253
+ }
254
+ catch (err) {
255
+ reject(new Error(`Failed to write file: ${err instanceof Error ? err.message : String(err)}`));
256
+ }
257
+ }
258
+ else {
259
+ // Read error response body
260
+ const chunks = [];
261
+ res.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
262
+ res.on('end', () => {
263
+ const body = Buffer.concat(chunks).toString('utf-8');
264
+ try {
265
+ const error = JSON.parse(body)?.error;
266
+ reject(new RemoteApiError(remoteName, res.statusCode ?? 500, error?.code ?? 'FILE_ERROR', error?.message ?? 'Failed to download file', error?.details));
267
+ }
268
+ catch {
269
+ reject(new RemoteApiError(remoteName, res.statusCode ?? 500, 'FILE_ERROR', body || 'Failed to download file'));
270
+ }
271
+ });
272
+ }
273
+ });
274
+ req.on('error', (err) => {
275
+ reject(new RemoteConnectionError(remoteName, remote.url, err.message));
276
+ });
277
+ req.on('timeout', () => {
278
+ req.destroy();
279
+ reject(new Error('Download timeout'));
280
+ });
281
+ req.end();
282
+ });
283
+ }
217
284
  /**
218
285
  * Test connection to a remote
219
286
  */