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