cli4ai 0.8.2 → 0.8.3

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.
@@ -0,0 +1,601 @@
1
+ /**
2
+ * Scheduler core - manages scheduled routine execution.
3
+ *
4
+ * Storage structure:
5
+ * ~/.cli4ai/scheduler/
6
+ * ├── scheduler.pid # Daemon PID
7
+ * ├── state.json # Next runs, last results
8
+ * ├── runs/ # Execution records
9
+ * │ └── <routine>-<ts>.json
10
+ * └── logs/
11
+ * └── scheduler.log # Daemon logs
12
+ */
13
+
14
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, readdirSync } from 'fs';
15
+ import { resolve, join } from 'path';
16
+ import parser from 'cron-parser';
17
+ import { SCHEDULER_DIR, ensureCli4aiHome } from './config.js';
18
+ import { getScheduledRoutines, type ScheduledRoutineInfo } from './routines.js';
19
+ import { loadRoutineDefinition, runRoutine, type RoutineSchedule, type RoutineRunSummary } from './routine-engine.js';
20
+
21
+ // ═══════════════════════════════════════════════════════════════════════════
22
+ // PATHS
23
+ // ═══════════════════════════════════════════════════════════════════════════
24
+
25
+ export const SCHEDULER_PID_FILE = resolve(SCHEDULER_DIR, 'scheduler.pid');
26
+ export const SCHEDULER_STATE_FILE = resolve(SCHEDULER_DIR, 'state.json');
27
+ export const SCHEDULER_RUNS_DIR = resolve(SCHEDULER_DIR, 'runs');
28
+ export const SCHEDULER_LOGS_DIR = resolve(SCHEDULER_DIR, 'logs');
29
+ export const SCHEDULER_LOG_FILE = resolve(SCHEDULER_LOGS_DIR, 'scheduler.log');
30
+
31
+ // ═══════════════════════════════════════════════════════════════════════════
32
+ // TYPES
33
+ // ═══════════════════════════════════════════════════════════════════════════
34
+
35
+ export interface ScheduledRoutineState {
36
+ name: string;
37
+ path: string;
38
+ schedule: RoutineSchedule;
39
+ nextRunAt: string | null;
40
+ lastRunAt: string | null;
41
+ lastStatus: 'success' | 'failed' | null;
42
+ running: boolean;
43
+ retryCount: number;
44
+ }
45
+
46
+ export interface SchedulerState {
47
+ version: 1;
48
+ startedAt: string;
49
+ routines: Record<string, ScheduledRoutineState>;
50
+ }
51
+
52
+ export interface SchedulerRunRecord {
53
+ routine: string;
54
+ startedAt: string;
55
+ finishedAt: string;
56
+ status: 'success' | 'failed';
57
+ exitCode: number;
58
+ durationMs: number;
59
+ retryAttempt: number;
60
+ error?: string;
61
+ }
62
+
63
+ // ═══════════════════════════════════════════════════════════════════════════
64
+ // INTERVAL PARSING
65
+ // ═══════════════════════════════════════════════════════════════════════════
66
+
67
+ const INTERVAL_PATTERN = /^(\d+)(s|m|h|d)$/;
68
+
69
+ const INTERVAL_MULTIPLIERS: Record<string, number> = {
70
+ s: 1000,
71
+ m: 60 * 1000,
72
+ h: 60 * 60 * 1000,
73
+ d: 24 * 60 * 60 * 1000
74
+ };
75
+
76
+ /**
77
+ * Parse an interval string like "30s", "5m", "1h", "1d" into milliseconds.
78
+ */
79
+ export function parseInterval(interval: string): number {
80
+ const match = interval.match(INTERVAL_PATTERN);
81
+ if (!match) {
82
+ throw new Error(`Invalid interval format: ${interval}`);
83
+ }
84
+
85
+ const value = parseInt(match[1], 10);
86
+ const unit = match[2];
87
+ return value * INTERVAL_MULTIPLIERS[unit];
88
+ }
89
+
90
+ /**
91
+ * Calculate the next run time for a schedule.
92
+ * If both cron and interval are specified, returns the earlier of the two.
93
+ */
94
+ export function getNextRunTime(schedule: RoutineSchedule, after: Date = new Date()): Date | null {
95
+ const candidates: Date[] = [];
96
+
97
+ // Calculate from cron expression
98
+ if (schedule.cron) {
99
+ try {
100
+ const options: parser.ParserOptions = {
101
+ currentDate: after,
102
+ tz: schedule.timezone
103
+ };
104
+ const cronExpr = parser.parseExpression(schedule.cron, options);
105
+ candidates.push(cronExpr.next().toDate());
106
+ } catch {
107
+ // Invalid cron expression - skip
108
+ }
109
+ }
110
+
111
+ // Calculate from interval
112
+ if (schedule.interval) {
113
+ try {
114
+ const intervalMs = parseInterval(schedule.interval);
115
+ candidates.push(new Date(after.getTime() + intervalMs));
116
+ } catch {
117
+ // Invalid interval - skip
118
+ }
119
+ }
120
+
121
+ if (candidates.length === 0) {
122
+ return null;
123
+ }
124
+
125
+ // Return the earliest next run time
126
+ return candidates.reduce((earliest, candidate) =>
127
+ candidate < earliest ? candidate : earliest
128
+ );
129
+ }
130
+
131
+ // ═══════════════════════════════════════════════════════════════════════════
132
+ // DAEMON LIFECYCLE
133
+ // ═══════════════════════════════════════════════════════════════════════════
134
+
135
+ /**
136
+ * Get the PID of the running daemon, or null if not running.
137
+ */
138
+ export function getDaemonPid(): number | null {
139
+ if (!existsSync(SCHEDULER_PID_FILE)) {
140
+ return null;
141
+ }
142
+
143
+ try {
144
+ const pid = parseInt(readFileSync(SCHEDULER_PID_FILE, 'utf-8').trim(), 10);
145
+ if (isNaN(pid)) return null;
146
+ return pid;
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Check if a process with the given PID is running.
154
+ */
155
+ function isProcessRunning(pid: number): boolean {
156
+ try {
157
+ // Sending signal 0 doesn't actually send a signal, but checks if process exists
158
+ process.kill(pid, 0);
159
+ return true;
160
+ } catch {
161
+ return false;
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Check if the scheduler daemon is running.
167
+ */
168
+ export function isDaemonRunning(): boolean {
169
+ const pid = getDaemonPid();
170
+ if (pid === null) return false;
171
+ return isProcessRunning(pid);
172
+ }
173
+
174
+ /**
175
+ * Write the daemon PID file.
176
+ */
177
+ export function writeDaemonPid(pid: number): void {
178
+ ensureSchedulerDirs();
179
+ writeFileSync(SCHEDULER_PID_FILE, String(pid));
180
+ }
181
+
182
+ /**
183
+ * Remove the daemon PID file.
184
+ */
185
+ export function removeDaemonPid(): void {
186
+ if (existsSync(SCHEDULER_PID_FILE)) {
187
+ unlinkSync(SCHEDULER_PID_FILE);
188
+ }
189
+ }
190
+
191
+ // ═══════════════════════════════════════════════════════════════════════════
192
+ // STATE MANAGEMENT
193
+ // ═══════════════════════════════════════════════════════════════════════════
194
+
195
+ export function ensureSchedulerDirs(): void {
196
+ ensureCli4aiHome();
197
+ if (!existsSync(SCHEDULER_RUNS_DIR)) {
198
+ mkdirSync(SCHEDULER_RUNS_DIR, { recursive: true });
199
+ }
200
+ if (!existsSync(SCHEDULER_LOGS_DIR)) {
201
+ mkdirSync(SCHEDULER_LOGS_DIR, { recursive: true });
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Load scheduler state from disk.
207
+ */
208
+ export function loadSchedulerState(): SchedulerState | null {
209
+ if (!existsSync(SCHEDULER_STATE_FILE)) {
210
+ return null;
211
+ }
212
+
213
+ try {
214
+ const content = readFileSync(SCHEDULER_STATE_FILE, 'utf-8');
215
+ return JSON.parse(content) as SchedulerState;
216
+ } catch {
217
+ return null;
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Save scheduler state to disk.
223
+ */
224
+ export function saveSchedulerState(state: SchedulerState): void {
225
+ ensureSchedulerDirs();
226
+ writeFileSync(SCHEDULER_STATE_FILE, JSON.stringify(state, null, 2));
227
+ }
228
+
229
+ /**
230
+ * Save a run record to disk.
231
+ */
232
+ export function saveRunRecord(record: SchedulerRunRecord): void {
233
+ ensureSchedulerDirs();
234
+ const filename = `${record.routine}-${new Date(record.startedAt).getTime()}.json`;
235
+ const filepath = resolve(SCHEDULER_RUNS_DIR, filename);
236
+ writeFileSync(filepath, JSON.stringify(record, null, 2));
237
+ }
238
+
239
+ /**
240
+ * Get run history for a routine (or all routines).
241
+ */
242
+ export function getRunHistory(routineName?: string, limit: number = 20): SchedulerRunRecord[] {
243
+ ensureSchedulerDirs();
244
+
245
+ if (!existsSync(SCHEDULER_RUNS_DIR)) {
246
+ return [];
247
+ }
248
+
249
+ const files = readdirSync(SCHEDULER_RUNS_DIR)
250
+ .filter(f => f.endsWith('.json'))
251
+ .filter(f => !routineName || f.startsWith(`${routineName}-`))
252
+ .sort()
253
+ .reverse()
254
+ .slice(0, limit);
255
+
256
+ const records: SchedulerRunRecord[] = [];
257
+
258
+ for (const file of files) {
259
+ try {
260
+ const content = readFileSync(resolve(SCHEDULER_RUNS_DIR, file), 'utf-8');
261
+ records.push(JSON.parse(content) as SchedulerRunRecord);
262
+ } catch {
263
+ // Skip invalid files
264
+ }
265
+ }
266
+
267
+ return records;
268
+ }
269
+
270
+ // ═══════════════════════════════════════════════════════════════════════════
271
+ // LOGGING
272
+ // ═══════════════════════════════════════════════════════════════════════════
273
+
274
+ export type LogLevel = 'info' | 'warn' | 'error' | 'debug';
275
+
276
+ export function appendSchedulerLog(level: LogLevel, message: string): void {
277
+ ensureSchedulerDirs();
278
+ const timestamp = new Date().toISOString();
279
+ const line = `[${timestamp}] [${level.toUpperCase()}] ${message}\n`;
280
+ const fd = require('fs').appendFileSync(SCHEDULER_LOG_FILE, line);
281
+ }
282
+
283
+ // ═══════════════════════════════════════════════════════════════════════════
284
+ // SCHEDULER CLASS
285
+ // ═══════════════════════════════════════════════════════════════════════════
286
+
287
+ export interface SchedulerOptions {
288
+ tickIntervalMs?: number;
289
+ projectDir?: string;
290
+ }
291
+
292
+ export class Scheduler {
293
+ private state: SchedulerState;
294
+ private tickIntervalMs: number;
295
+ private projectDir: string | undefined;
296
+ private running: boolean = false;
297
+ private tickTimer: NodeJS.Timeout | null = null;
298
+ private routineChainByName: Map<string, Promise<void>> = new Map();
299
+
300
+ constructor(options: SchedulerOptions = {}) {
301
+ this.tickIntervalMs = options.tickIntervalMs ?? 10000; // 10 seconds default
302
+ this.projectDir = options.projectDir;
303
+
304
+ // Load or initialize state
305
+ const existingState = loadSchedulerState();
306
+ if (existingState) {
307
+ this.state = existingState;
308
+ } else {
309
+ this.state = {
310
+ version: 1,
311
+ startedAt: new Date().toISOString(),
312
+ routines: {}
313
+ };
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Refresh the list of scheduled routines from disk.
319
+ */
320
+ refreshRoutines(): void {
321
+ const scheduled = getScheduledRoutines(this.projectDir);
322
+
323
+ // Remove routines that no longer exist or are disabled
324
+ const currentNames = new Set(scheduled.map(r => r.name));
325
+ for (const name of Object.keys(this.state.routines)) {
326
+ if (!currentNames.has(name)) {
327
+ delete this.state.routines[name];
328
+ }
329
+ }
330
+
331
+ // Add or update routines
332
+ for (const routine of scheduled) {
333
+ const existing = this.state.routines[routine.name];
334
+
335
+ if (existing) {
336
+ // Update schedule if changed
337
+ existing.schedule = routine.schedule;
338
+ existing.path = routine.path;
339
+ // Recalculate next run if schedule changed
340
+ if (!existing.nextRunAt) {
341
+ existing.nextRunAt = getNextRunTime(routine.schedule)?.toISOString() ?? null;
342
+ }
343
+ } else {
344
+ // New routine
345
+ this.state.routines[routine.name] = {
346
+ name: routine.name,
347
+ path: routine.path,
348
+ schedule: routine.schedule,
349
+ nextRunAt: getNextRunTime(routine.schedule)?.toISOString() ?? null,
350
+ lastRunAt: null,
351
+ lastStatus: null,
352
+ running: false,
353
+ retryCount: 0
354
+ };
355
+ }
356
+ }
357
+
358
+ saveSchedulerState(this.state);
359
+ }
360
+
361
+ /**
362
+ * Start the scheduler loop.
363
+ */
364
+ async start(): Promise<void> {
365
+ if (this.running) return;
366
+
367
+ this.running = true;
368
+ this.state.startedAt = new Date().toISOString();
369
+
370
+ appendSchedulerLog('info', 'Scheduler started');
371
+ this.refreshRoutines();
372
+
373
+ // Run tick loop
374
+ this.scheduleTick();
375
+ }
376
+
377
+ /**
378
+ * Stop the scheduler loop.
379
+ */
380
+ async stop(): Promise<void> {
381
+ this.running = false;
382
+
383
+ if (this.tickTimer) {
384
+ clearTimeout(this.tickTimer);
385
+ this.tickTimer = null;
386
+ }
387
+
388
+ appendSchedulerLog('info', 'Scheduler stopped');
389
+ saveSchedulerState(this.state);
390
+ }
391
+
392
+ /**
393
+ * Get current scheduler state (for status display).
394
+ */
395
+ getState(): SchedulerState {
396
+ return this.state;
397
+ }
398
+
399
+ private scheduleTick(): void {
400
+ if (!this.running) return;
401
+
402
+ this.tickTimer = setTimeout(async () => {
403
+ await this.tick();
404
+ this.scheduleTick();
405
+ }, this.tickIntervalMs);
406
+ }
407
+
408
+ private isRoutineActive(name: string): boolean {
409
+ return this.routineChainByName.has(name);
410
+ }
411
+
412
+ private enqueueRoutineExecution(routine: ScheduledRoutineState): void {
413
+ const name = routine.name;
414
+ const prev = this.routineChainByName.get(name) ?? Promise.resolve();
415
+ const next = prev
416
+ .catch(() => {
417
+ // Keep queue alive even if a previous run rejected unexpectedly.
418
+ })
419
+ .then(async () => {
420
+ try {
421
+ await this.executeRoutine(routine);
422
+ } catch (err) {
423
+ appendSchedulerLog(
424
+ 'error',
425
+ `Unexpected error executing routine ${routine.name}: ${err instanceof Error ? err.message : String(err)}`
426
+ );
427
+ }
428
+ });
429
+
430
+ this.routineChainByName.set(name, next);
431
+ next.finally(() => {
432
+ if (this.routineChainByName.get(name) === next) {
433
+ this.routineChainByName.delete(name);
434
+ }
435
+ });
436
+ }
437
+
438
+ /**
439
+ * Execute one tick - check for routines that need to run.
440
+ */
441
+ private async tick(): Promise<void> {
442
+ const now = new Date();
443
+
444
+ // Refresh routines periodically to pick up changes
445
+ this.refreshRoutines();
446
+
447
+ for (const [name, routine] of Object.entries(this.state.routines)) {
448
+ if (!routine.nextRunAt) continue;
449
+
450
+ const nextRun = new Date(routine.nextRunAt);
451
+ if (nextRun > now) continue;
452
+
453
+ const concurrency = routine.schedule.concurrency ?? 'skip';
454
+
455
+ // Advance next run time immediately to avoid re-triggering the same run.
456
+ routine.nextRunAt = getNextRunTime(routine.schedule, now)?.toISOString() ?? null;
457
+ saveSchedulerState(this.state);
458
+
459
+ if (this.isRoutineActive(name)) {
460
+ if (concurrency === 'skip') {
461
+ appendSchedulerLog('debug', `Skipping ${name}: still running from previous invocation`);
462
+ continue;
463
+ }
464
+
465
+ appendSchedulerLog('debug', `Queueing ${name}: previous invocation still running`);
466
+ this.enqueueRoutineExecution(routine);
467
+ continue;
468
+ }
469
+
470
+ // Execute the routine (async, doesn't block the tick loop)
471
+ this.enqueueRoutineExecution(routine);
472
+ }
473
+ }
474
+
475
+ /**
476
+ * Execute a routine (async, doesn't block the tick loop).
477
+ */
478
+ private async executeRoutine(routine: ScheduledRoutineState): Promise<void> {
479
+ const startedAt = new Date();
480
+
481
+ routine.running = true;
482
+ saveSchedulerState(this.state);
483
+
484
+ appendSchedulerLog('info', `Starting routine: ${routine.name}`);
485
+
486
+ let result: RoutineRunSummary | null = null;
487
+ let error: string | undefined;
488
+
489
+ try {
490
+ const def = loadRoutineDefinition(routine.path);
491
+ const invocationDir = this.projectDir ?? process.cwd();
492
+ result = await runRoutine(def, {}, invocationDir);
493
+ } catch (err) {
494
+ error = err instanceof Error ? err.message : String(err);
495
+ appendSchedulerLog('error', `Routine ${routine.name} failed: ${error}`);
496
+ }
497
+
498
+ const finishedAt = new Date();
499
+ const status: 'success' | 'failed' = result?.status === 'success' ? 'success' : 'failed';
500
+
501
+ // Log routine output
502
+ if (result) {
503
+ for (const step of result.steps) {
504
+ // Log stderr (where progress messages typically go)
505
+ if (step.stderr) {
506
+ for (const line of step.stderr.split('\n')) {
507
+ if (line.trim()) {
508
+ appendSchedulerLog('info', `[${routine.name}] ${line}`);
509
+ }
510
+ }
511
+ }
512
+ // Log stdout if it's not JSON (JSON output is typically for machine consumption)
513
+ if (step.stdout && !step.json) {
514
+ for (const line of step.stdout.split('\n')) {
515
+ if (line.trim()) {
516
+ appendSchedulerLog('info', `[${routine.name}] ${line}`);
517
+ }
518
+ }
519
+ }
520
+ }
521
+ }
522
+
523
+ // Save run record
524
+ const record: SchedulerRunRecord = {
525
+ routine: routine.name,
526
+ startedAt: startedAt.toISOString(),
527
+ finishedAt: finishedAt.toISOString(),
528
+ status,
529
+ exitCode: result?.exitCode ?? 1,
530
+ durationMs: finishedAt.getTime() - startedAt.getTime(),
531
+ retryAttempt: routine.retryCount,
532
+ error
533
+ };
534
+ saveRunRecord(record);
535
+
536
+ // Update routine state
537
+ routine.running = false;
538
+ routine.lastRunAt = finishedAt.toISOString();
539
+ routine.lastStatus = status;
540
+
541
+ // Handle retries
542
+ const maxRetries = routine.schedule.retries ?? 0;
543
+ if (status === 'failed' && routine.retryCount < maxRetries) {
544
+ routine.retryCount++;
545
+ const retryDelay = routine.schedule.retryDelayMs ?? 60000;
546
+ routine.nextRunAt = new Date(finishedAt.getTime() + retryDelay).toISOString();
547
+ appendSchedulerLog('info', `Scheduling retry ${routine.retryCount}/${maxRetries} for ${routine.name}`);
548
+ } else {
549
+ // Reset retry count and calculate next run
550
+ routine.retryCount = 0;
551
+ }
552
+
553
+ saveSchedulerState(this.state);
554
+ appendSchedulerLog('info', `Finished routine: ${routine.name} (${status})`);
555
+ }
556
+
557
+ /**
558
+ * Manually trigger a routine to run now.
559
+ */
560
+ async runNow(routineName: string): Promise<SchedulerRunRecord> {
561
+ const routine = this.state.routines[routineName];
562
+ if (!routine) {
563
+ throw new Error(`Routine not found: ${routineName}`);
564
+ }
565
+
566
+ const startedAt = new Date();
567
+ let result: RoutineRunSummary | null = null;
568
+ let error: string | undefined;
569
+
570
+ try {
571
+ const def = loadRoutineDefinition(routine.path);
572
+ const invocationDir = this.projectDir ?? process.cwd();
573
+ result = await runRoutine(def, {}, invocationDir);
574
+ } catch (err) {
575
+ error = err instanceof Error ? err.message : String(err);
576
+ }
577
+
578
+ const finishedAt = new Date();
579
+ const status: 'success' | 'failed' = result?.status === 'success' ? 'success' : 'failed';
580
+
581
+ const record: SchedulerRunRecord = {
582
+ routine: routineName,
583
+ startedAt: startedAt.toISOString(),
584
+ finishedAt: finishedAt.toISOString(),
585
+ status,
586
+ exitCode: result?.exitCode ?? 1,
587
+ durationMs: finishedAt.getTime() - startedAt.getTime(),
588
+ retryAttempt: 0,
589
+ error
590
+ };
591
+ saveRunRecord(record);
592
+
593
+ // Update state
594
+ routine.lastRunAt = finishedAt.toISOString();
595
+ routine.lastStatus = status;
596
+ routine.nextRunAt = getNextRunTime(routine.schedule, finishedAt)?.toISOString() ?? null;
597
+ saveSchedulerState(this.state);
598
+
599
+ return record;
600
+ }
601
+ }
package/src/lib/cli.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  /**
2
- * cli4ai - cliforai.com
2
+ * cli4ai - cli4ai.com
3
3
  * Standardized CLI framework for AI agent tools
4
4
  */
5
5
 
6
6
  import { Command } from 'commander';
7
7
 
8
- export const BRAND = 'cli4ai - cliforai.com';
9
- export const VERSION = '0.8.2';
8
+ export const BRAND = 'cli4ai - cli4ai.com';
9
+ export const VERSION = '0.8.3';
10
10
 
11
11
  // ═══════════════════════════════════════════════════════════════════════════
12
12
  // TYPES
@@ -36,10 +36,29 @@ export const ErrorCodes = {
36
36
  PARSE_ERROR: 'PARSE_ERROR',
37
37
  MANIFEST_ERROR: 'MANIFEST_ERROR',
38
38
  INSTALL_ERROR: 'INSTALL_ERROR',
39
+ NPM_ERROR: 'NPM_ERROR',
39
40
  } as const;
40
41
 
41
42
  export type ErrorCode = typeof ErrorCodes[keyof typeof ErrorCodes];
42
43
 
44
+ const EXIT_CODES: Record<string, number> = {
45
+ [ErrorCodes.NOT_FOUND]: 2,
46
+ [ErrorCodes.INVALID_INPUT]: 3,
47
+ [ErrorCodes.ENV_MISSING]: 4,
48
+ [ErrorCodes.MANIFEST_ERROR]: 4,
49
+ [ErrorCodes.INSTALL_ERROR]: 4,
50
+ [ErrorCodes.AUTH_FAILED]: 6,
51
+ [ErrorCodes.NETWORK_ERROR]: 7,
52
+ [ErrorCodes.RATE_LIMITED]: 8,
53
+ [ErrorCodes.TIMEOUT]: 9,
54
+ [ErrorCodes.PARSE_ERROR]: 10,
55
+ [ErrorCodes.NPM_ERROR]: 11,
56
+ };
57
+
58
+ function getExitCode(code: string): number {
59
+ return EXIT_CODES[code] ?? 1;
60
+ }
61
+
43
62
  // ═══════════════════════════════════════════════════════════════════════════
44
63
  // ENV VALIDATION
45
64
  // ═══════════════════════════════════════════════════════════════════════════
@@ -106,14 +125,15 @@ export function output(data: unknown): void {
106
125
  export function outputError(
107
126
  code: string,
108
127
  message: string,
109
- details?: Record<string, unknown>
128
+ details?: Record<string, unknown>,
129
+ exitCodeOverride?: number
110
130
  ): never {
111
131
  console.error(JSON.stringify({
112
132
  error: code,
113
133
  message,
114
134
  ...details
115
135
  }));
116
- process.exit(1);
136
+ process.exit(exitCodeOverride ?? getExitCode(code));
117
137
  }
118
138
 
119
139
  /**
@@ -73,18 +73,26 @@ export async function executeTool(
73
73
  entryPath: string,
74
74
  runtime: string,
75
75
  command: string,
76
- args: Record<string, string>
76
+ args: Record<string, string>,
77
+ argOrder?: string[]
77
78
  ): Promise<McpToolResult> {
78
79
  return new Promise((resolve) => {
79
80
  // Build command arguments
80
81
  const cmdArgs = [command];
81
- for (const [key, value] of Object.entries(args)) {
82
- if (value !== undefined && value !== '') {
83
- cmdArgs.push(value);
84
- }
82
+
83
+ // Prefer manifest-defined arg order (positional), fall back to stable ordering.
84
+ const orderedKeys = argOrder ?? Object.keys(args).sort();
85
+ for (const key of orderedKeys) {
86
+ const value = args[key];
87
+ if (value !== undefined && value !== '') cmdArgs.push(value);
85
88
  }
86
89
 
87
- const proc = spawn(runtime, ['run', entryPath, ...cmdArgs], {
90
+ const runtimeArgs =
91
+ runtime === 'node'
92
+ ? [entryPath, ...cmdArgs]
93
+ : ['run', entryPath, ...cmdArgs];
94
+
95
+ const proc = spawn(runtime, runtimeArgs, {
88
96
  stdio: ['pipe', 'pipe', 'pipe'],
89
97
  env: { ...process.env }
90
98
  });