cli4ai 0.8.0

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,725 @@
1
+ /**
2
+ * Structured routine runner (v1).
3
+ */
4
+
5
+ import { readFileSync } from 'fs';
6
+ import { spawn } from 'child_process';
7
+ import { executeTool, ExecuteToolError } from './execute.js';
8
+
9
+ export class RoutineParseError extends Error {
10
+ constructor(
11
+ public path: string,
12
+ message: string
13
+ ) {
14
+ super(message);
15
+ this.name = 'RoutineParseError';
16
+ }
17
+ }
18
+
19
+ export class RoutineValidationError extends Error {
20
+ constructor(
21
+ message: string,
22
+ public details?: Record<string, unknown>
23
+ ) {
24
+ super(message);
25
+ this.name = 'RoutineValidationError';
26
+ }
27
+ }
28
+
29
+ export class RoutineTemplateError extends Error {
30
+ constructor(
31
+ message: string,
32
+ public details?: Record<string, unknown>
33
+ ) {
34
+ super(message);
35
+ this.name = 'RoutineTemplateError';
36
+ }
37
+ }
38
+
39
+ export type StepCapture = 'inherit' | 'text' | 'json';
40
+
41
+ export interface RoutineVarDef {
42
+ default?: string;
43
+ }
44
+
45
+ export interface RoutineBaseStep {
46
+ id: string;
47
+ type: 'cli4ai' | 'set' | 'exec';
48
+ continueOnError?: boolean;
49
+ timeout?: number;
50
+ }
51
+
52
+ export interface RoutineC4aiStep extends RoutineBaseStep {
53
+ type: 'cli4ai';
54
+ package: string;
55
+ command?: string;
56
+ args?: string[];
57
+ env?: Record<string, string>;
58
+ stdin?: string;
59
+ capture?: StepCapture;
60
+ }
61
+
62
+ export interface RoutineSetStep extends RoutineBaseStep {
63
+ type: 'set';
64
+ vars: Record<string, string>;
65
+ }
66
+
67
+ export interface RoutineExecStep extends RoutineBaseStep {
68
+ type: 'exec';
69
+ cmd: string;
70
+ args?: string[];
71
+ env?: Record<string, string>;
72
+ stdin?: string;
73
+ capture?: Exclude<StepCapture, 'inherit'>;
74
+ }
75
+
76
+ export type RoutineStep = RoutineC4aiStep | RoutineSetStep | RoutineExecStep;
77
+
78
+ export interface RoutineDefinition {
79
+ version: 1;
80
+ name: string;
81
+ description?: string;
82
+ mcp?: { expose?: boolean; description?: string };
83
+ vars?: Record<string, RoutineVarDef>;
84
+ steps: RoutineStep[];
85
+ result?: unknown;
86
+ }
87
+
88
+ export interface StepRunResult {
89
+ id: string;
90
+ type: RoutineStep['type'];
91
+ status: 'success' | 'failed' | 'skipped';
92
+ exitCode?: number;
93
+ durationMs?: number;
94
+ stdout?: string;
95
+ stderr?: string;
96
+ json?: unknown;
97
+ error?: { code: string; message: string; details?: Record<string, unknown> };
98
+ }
99
+
100
+ export interface RoutineRunSummary {
101
+ routine: string;
102
+ version: number;
103
+ status: 'success' | 'failed';
104
+ exitCode: number;
105
+ durationMs: number;
106
+ vars: Record<string, unknown>;
107
+ steps: StepRunResult[];
108
+ result?: unknown;
109
+ }
110
+
111
+ export interface RoutineDryRunStep {
112
+ id: string;
113
+ type: RoutineStep['type'];
114
+ rendered: Record<string, unknown>;
115
+ }
116
+
117
+ export interface RoutineDryRunPlan {
118
+ routine: string;
119
+ version: number;
120
+ vars: Record<string, unknown>;
121
+ steps: RoutineDryRunStep[];
122
+ result?: unknown;
123
+ }
124
+
125
+ export function loadRoutineDefinition(path: string): RoutineDefinition {
126
+ let content: string;
127
+ try {
128
+ content = readFileSync(path, 'utf-8');
129
+ } catch (err) {
130
+ throw new RoutineParseError(path, `Failed to read: ${err instanceof Error ? err.message : String(err)}`);
131
+ }
132
+
133
+ let data: unknown;
134
+ try {
135
+ data = JSON.parse(content);
136
+ } catch {
137
+ throw new RoutineParseError(path, 'Invalid JSON');
138
+ }
139
+
140
+ return validateRoutineDefinition(data, path);
141
+ }
142
+
143
+ function validateRoutineDefinition(value: unknown, source?: string): RoutineDefinition {
144
+ if (!value || typeof value !== 'object') {
145
+ throw new RoutineValidationError('Routine must be an object', { source });
146
+ }
147
+
148
+ const obj = value as Record<string, unknown>;
149
+
150
+ if (obj.version !== 1) {
151
+ throw new RoutineValidationError('Invalid or missing "version" (must be 1)', { source, got: obj.version });
152
+ }
153
+
154
+ if (typeof obj.name !== 'string' || obj.name.length === 0) {
155
+ throw new RoutineValidationError('Invalid or missing "name"', { source, got: obj.name });
156
+ }
157
+
158
+ if (!Array.isArray(obj.steps)) {
159
+ throw new RoutineValidationError('Invalid or missing "steps" (must be an array)', { source });
160
+ }
161
+
162
+ const steps = obj.steps as unknown[];
163
+ const ids = new Set<string>();
164
+
165
+ for (const [idx, step] of steps.entries()) {
166
+ if (!step || typeof step !== 'object') {
167
+ throw new RoutineValidationError('Invalid step (must be an object)', { source, index: idx });
168
+ }
169
+
170
+ const s = step as Record<string, unknown>;
171
+
172
+ if (typeof s.id !== 'string' || s.id.length === 0) {
173
+ throw new RoutineValidationError('Step missing "id"', { source, index: idx });
174
+ }
175
+
176
+ if (ids.has(s.id)) {
177
+ throw new RoutineValidationError(`Duplicate step id: ${s.id}`, { source, id: s.id });
178
+ }
179
+ ids.add(s.id);
180
+
181
+ if (typeof s.type !== 'string' || !['cli4ai', 'set', 'exec'].includes(s.type)) {
182
+ throw new RoutineValidationError(`Invalid step type for "${s.id}"`, { source, id: s.id, got: s.type });
183
+ }
184
+
185
+ if (s.timeout !== undefined && typeof s.timeout !== 'number') {
186
+ throw new RoutineValidationError(`Invalid timeout for "${s.id}" (must be number ms)`, { source, id: s.id, got: s.timeout });
187
+ }
188
+
189
+ if (s.continueOnError !== undefined && typeof s.continueOnError !== 'boolean') {
190
+ throw new RoutineValidationError(`Invalid continueOnError for "${s.id}" (must be boolean)`, { source, id: s.id, got: s.continueOnError });
191
+ }
192
+
193
+ if (s.type === 'cli4ai') {
194
+ if (typeof s.package !== 'string' || s.package.length === 0) {
195
+ throw new RoutineValidationError(`Step "${s.id}" missing "package"`, { source, id: s.id });
196
+ }
197
+ if (s.command !== undefined && typeof s.command !== 'string') {
198
+ throw new RoutineValidationError(`Step "${s.id}" has invalid "command"`, { source, id: s.id });
199
+ }
200
+ if (s.args !== undefined && (!Array.isArray(s.args) || s.args.some(a => typeof a !== 'string'))) {
201
+ throw new RoutineValidationError(`Step "${s.id}" has invalid "args" (must be string array)`, { source, id: s.id });
202
+ }
203
+ if (s.env !== undefined) {
204
+ if (typeof s.env !== 'object' || s.env === null) {
205
+ throw new RoutineValidationError(`Step "${s.id}" has invalid "env" (must be object)`, { source, id: s.id });
206
+ }
207
+ for (const [k, v] of Object.entries(s.env as Record<string, unknown>)) {
208
+ if (typeof v !== 'string') {
209
+ throw new RoutineValidationError(`Step "${s.id}" env.${k} must be a string template`, { source, id: s.id });
210
+ }
211
+ }
212
+ }
213
+ if (s.stdin !== undefined && typeof s.stdin !== 'string') {
214
+ throw new RoutineValidationError(`Step "${s.id}" has invalid "stdin"`, { source, id: s.id });
215
+ }
216
+ if (s.capture !== undefined && !['inherit', 'text', 'json'].includes(String(s.capture))) {
217
+ throw new RoutineValidationError(`Step "${s.id}" has invalid "capture"`, { source, id: s.id, got: s.capture });
218
+ }
219
+ }
220
+
221
+ if (s.type === 'set') {
222
+ if (typeof s.vars !== 'object' || s.vars === null) {
223
+ throw new RoutineValidationError(`Step "${s.id}" missing "vars"`, { source, id: s.id });
224
+ }
225
+ for (const [k, v] of Object.entries(s.vars as Record<string, unknown>)) {
226
+ if (typeof v !== 'string') {
227
+ throw new RoutineValidationError(`Step "${s.id}" vars.${k} must be a string template`, { source, id: s.id });
228
+ }
229
+ }
230
+ }
231
+
232
+ if (s.type === 'exec') {
233
+ if (typeof s.cmd !== 'string' || s.cmd.length === 0) {
234
+ throw new RoutineValidationError(`Step "${s.id}" missing "cmd"`, { source, id: s.id });
235
+ }
236
+ if (s.args !== undefined && (!Array.isArray(s.args) || s.args.some(a => typeof a !== 'string'))) {
237
+ throw new RoutineValidationError(`Step "${s.id}" has invalid "args" (must be string array)`, { source, id: s.id });
238
+ }
239
+ if (s.env !== undefined) {
240
+ if (typeof s.env !== 'object' || s.env === null) {
241
+ throw new RoutineValidationError(`Step "${s.id}" has invalid "env" (must be object)`, { source, id: s.id });
242
+ }
243
+ for (const [k, v] of Object.entries(s.env as Record<string, unknown>)) {
244
+ if (typeof v !== 'string') {
245
+ throw new RoutineValidationError(`Step "${s.id}" env.${k} must be a string template`, { source, id: s.id });
246
+ }
247
+ }
248
+ }
249
+ if (s.stdin !== undefined && typeof s.stdin !== 'string') {
250
+ throw new RoutineValidationError(`Step "${s.id}" has invalid "stdin"`, { source, id: s.id });
251
+ }
252
+ if (s.capture !== undefined && !['text', 'json'].includes(String(s.capture))) {
253
+ throw new RoutineValidationError(`Step "${s.id}" has invalid "capture"`, { source, id: s.id, got: s.capture });
254
+ }
255
+ }
256
+ }
257
+
258
+ return obj as RoutineDefinition;
259
+ }
260
+
261
+ function getPath(root: unknown, segments: string[]): unknown {
262
+ let current: unknown = root;
263
+ for (const seg of segments) {
264
+ if (current === null || current === undefined) return undefined;
265
+
266
+ if (Array.isArray(current)) {
267
+ const idx = Number(seg);
268
+ if (!Number.isInteger(idx)) return undefined;
269
+ current = current[idx];
270
+ continue;
271
+ }
272
+
273
+ if (typeof current !== 'object') return undefined;
274
+
275
+ current = (current as Record<string, unknown>)[seg];
276
+ }
277
+ return current;
278
+ }
279
+
280
+ function renderTemplateString(
281
+ template: string,
282
+ ctx: Record<string, unknown>,
283
+ meta: { stepId?: string; field?: string; strict?: boolean }
284
+ ): unknown {
285
+ const regex = /\{\{\s*([^}]+?)\s*\}\}/g;
286
+ const matches = [...template.matchAll(regex)];
287
+ if (matches.length === 0) return template;
288
+
289
+ const resolveExpr = (expr: string): unknown => {
290
+ const parts = expr.split('.').map(s => s.trim()).filter(Boolean);
291
+ const value = getPath(ctx, parts);
292
+ if (value === undefined) {
293
+ if (meta.strict === false) {
294
+ return `{{${expr}}}`;
295
+ }
296
+ throw new RoutineTemplateError(`Template error${meta.stepId ? ` in step "${meta.stepId}"` : ''}: path "${expr}" not found`, {
297
+ step: meta.stepId,
298
+ field: meta.field,
299
+ path: expr
300
+ });
301
+ }
302
+ return value;
303
+ };
304
+
305
+ // If the template is exactly a single {{expr}}, return the raw value.
306
+ if (matches.length === 1 && matches[0].index === 0 && matches[0][0].length === template.length) {
307
+ return resolveExpr(matches[0][1]);
308
+ }
309
+
310
+ let out = '';
311
+ let lastIndex = 0;
312
+ for (const m of matches) {
313
+ const full = m[0];
314
+ const expr = m[1];
315
+ const idx = m.index ?? 0;
316
+ out += template.slice(lastIndex, idx);
317
+ const value = resolveExpr(expr);
318
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
319
+ out += String(value);
320
+ } else {
321
+ out += JSON.stringify(value);
322
+ }
323
+ lastIndex = idx + full.length;
324
+ }
325
+ out += template.slice(lastIndex);
326
+ return out;
327
+ }
328
+
329
+ function renderString(value: string, ctx: Record<string, unknown>, meta: { stepId?: string; field?: string; strict?: boolean }): string {
330
+ const rendered = renderTemplateString(value, ctx, meta);
331
+ if (rendered === undefined || rendered === null) {
332
+ throw new RoutineTemplateError(`Template error${meta.stepId ? ` in step "${meta.stepId}"` : ''}: "${value}" resolved to empty`, {
333
+ step: meta.stepId,
334
+ field: meta.field
335
+ });
336
+ }
337
+ if (typeof rendered === 'string') return rendered;
338
+ if (typeof rendered === 'number' || typeof rendered === 'boolean') return String(rendered);
339
+ try {
340
+ return JSON.stringify(rendered);
341
+ } catch {
342
+ throw new RoutineTemplateError(`Template error${meta.stepId ? ` in step "${meta.stepId}"` : ''}: "${value}" resolved to non-serializable value`, {
343
+ step: meta.stepId,
344
+ field: meta.field
345
+ });
346
+ }
347
+ }
348
+
349
+ function renderScalarString(value: string, ctx: Record<string, unknown>, meta: { stepId?: string; field?: string; strict?: boolean }): string {
350
+ const rendered = renderTemplateString(value, ctx, meta);
351
+ if (rendered === undefined || rendered === null) {
352
+ throw new RoutineTemplateError(`Template error${meta.stepId ? ` in step "${meta.stepId}"` : ''}: "${value}" resolved to empty`, {
353
+ step: meta.stepId,
354
+ field: meta.field
355
+ });
356
+ }
357
+ if (typeof rendered === 'string' || typeof rendered === 'number' || typeof rendered === 'boolean') {
358
+ return String(rendered);
359
+ }
360
+ throw new RoutineTemplateError(
361
+ `Template error${meta.stepId ? ` in step "${meta.stepId}"` : ''}: "${value}" did not resolve to a string`,
362
+ {
363
+ step: meta.stepId,
364
+ field: meta.field,
365
+ gotType: Array.isArray(rendered) ? 'array' : typeof rendered
366
+ }
367
+ );
368
+ }
369
+
370
+ function renderStringArray(values: string[] | undefined, ctx: Record<string, unknown>, meta: { stepId?: string; field?: string; strict?: boolean }): string[] {
371
+ if (!values) return [];
372
+ return values.map((v, i) => renderString(v, ctx, { ...meta, field: `${meta.field ?? 'args'}.${i}` }));
373
+ }
374
+
375
+ function renderEnv(env: Record<string, string> | undefined, ctx: Record<string, unknown>, meta: { stepId?: string; field?: string; strict?: boolean }): Record<string, string> {
376
+ if (!env) return {};
377
+ const out: Record<string, string> = {};
378
+ for (const [k, v] of Object.entries(env)) {
379
+ out[k] = renderString(v, ctx, { ...meta, field: `${meta.field ?? 'env'}.${k}` });
380
+ }
381
+ return out;
382
+ }
383
+
384
+ function renderTemplateValue(value: unknown, ctx: Record<string, unknown>, meta: { stepId?: string; field?: string; strict?: boolean }): unknown {
385
+ if (typeof value === 'string') {
386
+ return renderTemplateString(value, ctx, meta);
387
+ }
388
+ if (Array.isArray(value)) {
389
+ return value.map((item, idx) => renderTemplateValue(item, ctx, { ...meta, field: `${meta.field ?? 'value'}.${idx}` }));
390
+ }
391
+ if (value && typeof value === 'object') {
392
+ const out: Record<string, unknown> = {};
393
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
394
+ out[k] = renderTemplateValue(v, ctx, { ...meta, field: `${meta.field ?? 'value'}.${k}` });
395
+ }
396
+ return out;
397
+ }
398
+ return value;
399
+ }
400
+
401
+ function collectStream(stream: NodeJS.ReadableStream): Promise<string> {
402
+ return new Promise((resolve, reject) => {
403
+ const chunks: Buffer[] = [];
404
+ stream.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))));
405
+ stream.on('error', reject);
406
+ stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
407
+ });
408
+ }
409
+
410
+ async function runExecStep(step: RoutineExecStep, ctx: Record<string, unknown>, invocationDir: string): Promise<StepRunResult> {
411
+ const startTime = Date.now();
412
+
413
+ const cmd = renderScalarString(step.cmd, ctx, { stepId: step.id, field: 'cmd' });
414
+ const args = renderStringArray(step.args, ctx, { stepId: step.id, field: 'args' });
415
+ const env = renderEnv(step.env, ctx, { stepId: step.id, field: 'env' });
416
+ const stdin = step.stdin !== undefined ? renderString(step.stdin, ctx, { stepId: step.id, field: 'stdin' }) : undefined;
417
+
418
+ const child = spawn(cmd, args, {
419
+ cwd: invocationDir,
420
+ stdio: ['pipe', 'pipe', 'pipe'],
421
+ env: {
422
+ ...process.env,
423
+ ...(env ?? {})
424
+ }
425
+ });
426
+
427
+ if (stdin !== undefined && child.stdin) {
428
+ child.stdin.write(stdin);
429
+ }
430
+ if (child.stdin) child.stdin.end();
431
+
432
+ const stdoutPromise = child.stdout ? collectStream(child.stdout) : Promise.resolve('');
433
+ const stderrPromise = child.stderr ? collectStream(child.stderr) : Promise.resolve('');
434
+
435
+ if (child.stderr) {
436
+ child.stderr.on('data', (chunk) => process.stderr.write(chunk));
437
+ }
438
+
439
+ let timeout: NodeJS.Timeout | undefined;
440
+ if (step.timeout && step.timeout > 0) {
441
+ timeout = setTimeout(() => {
442
+ try {
443
+ child.kill('SIGTERM');
444
+ } catch {}
445
+ setTimeout(() => {
446
+ try {
447
+ child.kill('SIGKILL');
448
+ } catch {}
449
+ }, 250);
450
+ }, step.timeout);
451
+ }
452
+
453
+ const exitCode = await new Promise<number>((resolve, reject) => {
454
+ child.on('close', (code) => resolve(code ?? 0));
455
+ child.on('error', (err) => reject(new ExecuteToolError('API_ERROR', `Failed to execute: ${err.message}`)));
456
+ }).finally(() => {
457
+ if (timeout) clearTimeout(timeout);
458
+ });
459
+
460
+ const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]);
461
+
462
+ const capture = step.capture ?? 'text';
463
+ let json: unknown | undefined;
464
+ if (capture === 'json') {
465
+ try {
466
+ json = JSON.parse(stdout);
467
+ } catch {
468
+ throw new RoutineTemplateError(`JSON parse error in step "${step.id}": stdout is not valid JSON`, {
469
+ step: step.id,
470
+ stdout: stdout.slice(0, 200)
471
+ });
472
+ }
473
+ }
474
+
475
+ return {
476
+ id: step.id,
477
+ type: step.type,
478
+ status: exitCode === 0 ? 'success' : 'failed',
479
+ exitCode,
480
+ durationMs: Date.now() - startTime,
481
+ stdout,
482
+ stderr,
483
+ json
484
+ };
485
+ }
486
+
487
+ export async function dryRunRoutine(def: RoutineDefinition, vars: Record<string, string>, invocationDir: string): Promise<RoutineDryRunPlan> {
488
+ const ctxVars: Record<string, unknown> = {};
489
+
490
+ for (const [k, v] of Object.entries(def.vars ?? {})) {
491
+ if (v.default !== undefined) ctxVars[k] = v.default;
492
+ }
493
+ for (const [k, v] of Object.entries(vars)) ctxVars[k] = v;
494
+
495
+ const ctx: Record<string, unknown> = {
496
+ vars: ctxVars,
497
+ steps: {}
498
+ };
499
+
500
+ const steps: RoutineDryRunStep[] = [];
501
+
502
+ for (const step of def.steps) {
503
+ if (step.type === 'set') {
504
+ const renderedVars: Record<string, unknown> = {};
505
+ for (const [k, v] of Object.entries(step.vars)) {
506
+ renderedVars[k] = renderTemplateString(v, ctx, { stepId: step.id, field: `vars.${k}`, strict: false });
507
+ }
508
+ // Apply to context for subsequent steps
509
+ for (const [k, v] of Object.entries(renderedVars)) ctxVars[k] = v;
510
+
511
+ steps.push({ id: step.id, type: step.type, rendered: { vars: renderedVars } });
512
+ continue;
513
+ }
514
+
515
+ if (step.type === 'cli4ai') {
516
+ const rendered = {
517
+ package: renderScalarString(step.package, ctx, { stepId: step.id, field: 'package', strict: false }),
518
+ command: step.command ? renderScalarString(step.command, ctx, { stepId: step.id, field: 'command', strict: false }) : undefined,
519
+ args: renderStringArray(step.args, ctx, { stepId: step.id, field: 'args', strict: false }),
520
+ env: renderEnv(step.env, ctx, { stepId: step.id, field: 'env', strict: false }),
521
+ stdin: step.stdin ? renderString(step.stdin, ctx, { stepId: step.id, field: 'stdin', strict: false }) : undefined,
522
+ timeout: step.timeout
523
+ };
524
+ steps.push({ id: step.id, type: step.type, rendered });
525
+ continue;
526
+ }
527
+
528
+ if (step.type === 'exec') {
529
+ const rendered = {
530
+ cmd: renderScalarString(step.cmd, ctx, { stepId: step.id, field: 'cmd', strict: false }),
531
+ args: renderStringArray(step.args, ctx, { stepId: step.id, field: 'args', strict: false }),
532
+ env: renderEnv(step.env, ctx, { stepId: step.id, field: 'env', strict: false }),
533
+ stdin: step.stdin ? renderString(step.stdin, ctx, { stepId: step.id, field: 'stdin', strict: false }) : undefined,
534
+ timeout: step.timeout
535
+ };
536
+ steps.push({ id: step.id, type: step.type, rendered });
537
+ continue;
538
+ }
539
+ }
540
+
541
+ const result = def.result !== undefined ? renderTemplateValue(def.result, ctx, { field: 'result', strict: false }) : undefined;
542
+
543
+ return {
544
+ routine: def.name,
545
+ version: def.version,
546
+ vars: ctxVars,
547
+ steps,
548
+ result
549
+ };
550
+ }
551
+
552
+ export async function runRoutine(def: RoutineDefinition, vars: Record<string, string>, invocationDir: string): Promise<RoutineRunSummary> {
553
+ const startTime = Date.now();
554
+
555
+ const ctxVars: Record<string, unknown> = {};
556
+ for (const [k, v] of Object.entries(def.vars ?? {})) {
557
+ if (v.default !== undefined) ctxVars[k] = v.default;
558
+ }
559
+ for (const [k, v] of Object.entries(vars)) ctxVars[k] = v;
560
+
561
+ const stepsById: Record<string, StepRunResult> = {};
562
+
563
+ const ctx: Record<string, unknown> = {
564
+ vars: ctxVars,
565
+ steps: stepsById
566
+ };
567
+
568
+ const steps: StepRunResult[] = [];
569
+
570
+ for (const step of def.steps) {
571
+ if (step.type === 'set') {
572
+ const assigned: Record<string, unknown> = {};
573
+ for (const [k, v] of Object.entries(step.vars)) {
574
+ assigned[k] = renderTemplateString(v, ctx, { stepId: step.id, field: `vars.${k}` });
575
+ }
576
+ for (const [k, v] of Object.entries(assigned)) ctxVars[k] = v;
577
+
578
+ const res: StepRunResult = {
579
+ id: step.id,
580
+ type: step.type,
581
+ status: 'success',
582
+ exitCode: 0,
583
+ durationMs: 0,
584
+ json: assigned
585
+ };
586
+ stepsById[step.id] = res;
587
+ steps.push(res);
588
+ continue;
589
+ }
590
+
591
+ if (step.type === 'exec') {
592
+ try {
593
+ const res = await runExecStep(step, ctx, invocationDir);
594
+ stepsById[step.id] = res;
595
+ steps.push(res);
596
+
597
+ if (res.exitCode !== 0 && !step.continueOnError) {
598
+ return finalizeFailure(def, ctxVars, steps, Date.now() - startTime, res.exitCode ?? 1);
599
+ }
600
+ continue;
601
+ } catch (err) {
602
+ const error = normalizeError(err);
603
+ const res: StepRunResult = { id: step.id, type: step.type, status: 'failed', exitCode: 1, error };
604
+ stepsById[step.id] = res;
605
+ steps.push(res);
606
+ if (!step.continueOnError) {
607
+ return finalizeFailure(def, ctxVars, steps, Date.now() - startTime, 1);
608
+ }
609
+ continue;
610
+ }
611
+ }
612
+
613
+ if (step.type === 'cli4ai') {
614
+ const pkg = renderScalarString(step.package, ctx, { stepId: step.id, field: 'package' });
615
+ const cmd = step.command !== undefined ? renderScalarString(step.command, ctx, { stepId: step.id, field: 'command' }) : undefined;
616
+ const args = renderStringArray(step.args, ctx, { stepId: step.id, field: 'args' });
617
+ const env = renderEnv(step.env, ctx, { stepId: step.id, field: 'env' });
618
+ const stdin = step.stdin !== undefined ? renderString(step.stdin, ctx, { stepId: step.id, field: 'stdin' }) : undefined;
619
+
620
+ const capture = step.capture ?? 'text';
621
+
622
+ try {
623
+ const execRes = await executeTool({
624
+ packageName: pkg,
625
+ command: cmd,
626
+ args,
627
+ cwd: invocationDir,
628
+ env,
629
+ stdin,
630
+ capture: 'pipe',
631
+ timeoutMs: step.timeout,
632
+ teeStderr: true
633
+ });
634
+
635
+ const res: StepRunResult = {
636
+ id: step.id,
637
+ type: step.type,
638
+ status: execRes.exitCode === 0 ? 'success' : 'failed',
639
+ exitCode: execRes.exitCode,
640
+ durationMs: execRes.durationMs,
641
+ stdout: execRes.stdout,
642
+ stderr: execRes.stderr
643
+ };
644
+
645
+ if (capture === 'json') {
646
+ try {
647
+ res.json = execRes.stdout ? JSON.parse(execRes.stdout) : null;
648
+ } catch {
649
+ throw new RoutineTemplateError(`JSON parse error in step "${step.id}": stdout is not valid JSON`, {
650
+ step: step.id,
651
+ stdout: (execRes.stdout ?? '').slice(0, 200)
652
+ });
653
+ }
654
+ }
655
+
656
+ stepsById[step.id] = res;
657
+ steps.push(res);
658
+
659
+ if (execRes.exitCode !== 0 && !step.continueOnError) {
660
+ return finalizeFailure(def, ctxVars, steps, Date.now() - startTime, execRes.exitCode);
661
+ }
662
+ continue;
663
+ } catch (err) {
664
+ const error = normalizeError(err);
665
+ const res: StepRunResult = {
666
+ id: step.id,
667
+ type: step.type,
668
+ status: 'failed',
669
+ exitCode: error.code === 'INTEGRITY_ERROR' ? 1 : 1,
670
+ error
671
+ };
672
+ stepsById[step.id] = res;
673
+ steps.push(res);
674
+ if (!step.continueOnError) {
675
+ return finalizeFailure(def, ctxVars, steps, Date.now() - startTime, 1);
676
+ }
677
+ continue;
678
+ }
679
+ }
680
+ }
681
+
682
+ const result = def.result !== undefined ? renderTemplateValue(def.result, ctx, { field: 'result' }) : undefined;
683
+
684
+ return {
685
+ routine: def.name,
686
+ version: def.version,
687
+ status: 'success',
688
+ exitCode: 0,
689
+ durationMs: Date.now() - startTime,
690
+ vars: ctxVars,
691
+ steps,
692
+ result
693
+ };
694
+ }
695
+
696
+ function normalizeError(err: unknown): { code: string; message: string; details?: Record<string, unknown> } {
697
+ if (err instanceof ExecuteToolError) {
698
+ return { code: err.code, message: err.message, details: err.details };
699
+ }
700
+ if (err instanceof RoutineTemplateError) {
701
+ return { code: 'INVALID_INPUT', message: err.message, details: err.details };
702
+ }
703
+ if (err instanceof RoutineValidationError) {
704
+ return { code: 'INVALID_INPUT', message: err.message, details: err.details };
705
+ }
706
+ if (err instanceof RoutineParseError) {
707
+ return { code: 'PARSE_ERROR', message: err.message, details: { path: err.path } };
708
+ }
709
+ if (err instanceof Error) {
710
+ return { code: 'API_ERROR', message: err.message };
711
+ }
712
+ return { code: 'API_ERROR', message: String(err) };
713
+ }
714
+
715
+ function finalizeFailure(def: RoutineDefinition, vars: Record<string, unknown>, steps: StepRunResult[], durationMs: number, exitCode: number): RoutineRunSummary {
716
+ return {
717
+ routine: def.name,
718
+ version: def.version,
719
+ status: 'failed',
720
+ exitCode,
721
+ durationMs,
722
+ vars,
723
+ steps
724
+ };
725
+ }