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
@@ -1,895 +0,0 @@
1
- /**
2
- * Structured routine runner (v1).
3
- */
4
-
5
- import { readFileSync } from 'fs';
6
- import { spawn } from 'child_process';
7
- import { parse as parseYaml } from 'yaml';
8
- import { executeTool, ExecuteToolError } from './execute.js';
9
- import { remoteRunTool, RemoteConnectionError, RemoteApiError } from './remote-client.js';
10
- import { getRemote } from './remotes.js';
11
-
12
- export class RoutineParseError extends Error {
13
- constructor(
14
- public path: string,
15
- message: string
16
- ) {
17
- super(message);
18
- this.name = 'RoutineParseError';
19
- }
20
- }
21
-
22
- export class RoutineValidationError extends Error {
23
- constructor(
24
- message: string,
25
- public details?: Record<string, unknown>
26
- ) {
27
- super(message);
28
- this.name = 'RoutineValidationError';
29
- }
30
- }
31
-
32
- export class RoutineTemplateError extends Error {
33
- constructor(
34
- message: string,
35
- public details?: Record<string, unknown>
36
- ) {
37
- super(message);
38
- this.name = 'RoutineTemplateError';
39
- }
40
- }
41
-
42
- export type StepCapture = 'inherit' | 'text' | 'json';
43
-
44
- // ═══════════════════════════════════════════════════════════════════════════
45
- // SCHEDULE TYPES
46
- // ═══════════════════════════════════════════════════════════════════════════
47
-
48
- export interface RoutineSchedule {
49
- /** Cron expression (e.g., "0 9 * * *" for 9am daily) */
50
- cron?: string;
51
- /** Simple interval (e.g., "30s", "5m", "1h", "1d") */
52
- interval?: string;
53
- /** IANA timezone (e.g., "Pacific/Auckland"). Defaults to system timezone */
54
- timezone?: string;
55
- /** Whether this schedule is active. Defaults to true */
56
- enabled?: boolean;
57
- /** Number of retry attempts on failure. Defaults to 0 */
58
- retries?: number;
59
- /** Delay between retries in milliseconds. Defaults to 60000 */
60
- retryDelayMs?: number;
61
- /** What to do if previous run is still executing. Defaults to 'skip' */
62
- concurrency?: 'skip' | 'queue';
63
- }
64
-
65
- export interface RoutineVarDef {
66
- default?: string;
67
- }
68
-
69
- export interface RoutineBaseStep {
70
- id: string;
71
- type: 'cli4ai' | 'set' | 'exec';
72
- continueOnError?: boolean;
73
- timeout?: number;
74
- }
75
-
76
- export interface RoutineC4aiStep extends RoutineBaseStep {
77
- type: 'cli4ai';
78
- package: string;
79
- command?: string;
80
- args?: string[];
81
- env?: Record<string, string>;
82
- stdin?: string;
83
- capture?: StepCapture;
84
- /** Name of a configured remote to execute on (optional) */
85
- remote?: string;
86
- }
87
-
88
- export interface RoutineSetStep extends RoutineBaseStep {
89
- type: 'set';
90
- vars: Record<string, string>;
91
- }
92
-
93
- export interface RoutineExecStep extends RoutineBaseStep {
94
- type: 'exec';
95
- cmd: string;
96
- args?: string[];
97
- env?: Record<string, string>;
98
- stdin?: string;
99
- capture?: Exclude<StepCapture, 'inherit'>;
100
- }
101
-
102
- export type RoutineStep = RoutineC4aiStep | RoutineSetStep | RoutineExecStep;
103
-
104
- export interface RoutineDefinition {
105
- version: 1;
106
- name: string;
107
- description?: string;
108
- mcp?: { expose?: boolean; description?: string };
109
- vars?: Record<string, RoutineVarDef>;
110
- /** Schedule configuration for automatic execution */
111
- schedule?: RoutineSchedule;
112
- steps: RoutineStep[];
113
- result?: unknown;
114
- }
115
-
116
- export interface StepRunResult {
117
- id: string;
118
- type: RoutineStep['type'];
119
- status: 'success' | 'failed' | 'skipped';
120
- exitCode?: number;
121
- durationMs?: number;
122
- stdout?: string;
123
- stderr?: string;
124
- json?: unknown;
125
- error?: { code: string; message: string; details?: Record<string, unknown> };
126
- }
127
-
128
- export interface RoutineRunSummary {
129
- routine: string;
130
- version: number;
131
- status: 'success' | 'failed';
132
- exitCode: number;
133
- durationMs: number;
134
- vars: Record<string, unknown>;
135
- steps: StepRunResult[];
136
- result?: unknown;
137
- }
138
-
139
- export interface RoutineDryRunStep {
140
- id: string;
141
- type: RoutineStep['type'];
142
- rendered: Record<string, unknown>;
143
- }
144
-
145
- export interface RoutineDryRunPlan {
146
- routine: string;
147
- version: number;
148
- vars: Record<string, unknown>;
149
- steps: RoutineDryRunStep[];
150
- result?: unknown;
151
- }
152
-
153
- export function loadRoutineDefinition(path: string): RoutineDefinition {
154
- let content: string;
155
- try {
156
- content = readFileSync(path, 'utf-8');
157
- } catch (err) {
158
- throw new RoutineParseError(path, `Failed to read: ${err instanceof Error ? err.message : String(err)}`);
159
- }
160
-
161
- const isYaml = path.endsWith('.yaml') || path.endsWith('.yml');
162
- let data: unknown;
163
- try {
164
- data = isYaml ? parseYaml(content) : JSON.parse(content);
165
- } catch (err) {
166
- const format = isYaml ? 'YAML' : 'JSON';
167
- throw new RoutineParseError(path, `Invalid ${format}: ${err instanceof Error ? err.message : String(err)}`);
168
- }
169
-
170
- return validateRoutineDefinition(data, path);
171
- }
172
-
173
- const INTERVAL_PATTERN = /^(\d+)(s|m|h|d)$/;
174
-
175
- /**
176
- * Validate a schedule configuration.
177
- * Exported for use by scheduler and tests.
178
- */
179
- export function validateScheduleConfig(schedule: unknown, source?: string): RoutineSchedule {
180
- if (!schedule || typeof schedule !== 'object') {
181
- throw new RoutineValidationError('Schedule must be an object', { source });
182
- }
183
-
184
- const s = schedule as Record<string, unknown>;
185
-
186
- // Must have either cron or interval (or both)
187
- if (s.cron === undefined && s.interval === undefined) {
188
- throw new RoutineValidationError('Schedule must have "cron" or "interval"', { source });
189
- }
190
-
191
- // Validate cron (basic format check - full validation happens at runtime with cron-parser)
192
- if (s.cron !== undefined) {
193
- if (typeof s.cron !== 'string' || s.cron.trim().length === 0) {
194
- throw new RoutineValidationError('Schedule "cron" must be a non-empty string', { source, got: s.cron });
195
- }
196
- // Basic cron format: should have 5 space-separated parts
197
- const parts = s.cron.trim().split(/\s+/);
198
- if (parts.length < 5 || parts.length > 6) {
199
- throw new RoutineValidationError('Schedule "cron" must have 5 or 6 fields', { source, got: s.cron });
200
- }
201
- }
202
-
203
- // Validate interval
204
- if (s.interval !== undefined) {
205
- if (typeof s.interval !== 'string') {
206
- throw new RoutineValidationError('Schedule "interval" must be a string', { source, got: s.interval });
207
- }
208
- if (!INTERVAL_PATTERN.test(s.interval)) {
209
- throw new RoutineValidationError('Schedule "interval" must be like "30s", "5m", "1h", or "1d"', { source, got: s.interval });
210
- }
211
- }
212
-
213
- // Validate timezone (just check it's a string - actual validation happens at runtime)
214
- if (s.timezone !== undefined && typeof s.timezone !== 'string') {
215
- throw new RoutineValidationError('Schedule "timezone" must be a string', { source, got: s.timezone });
216
- }
217
-
218
- // Validate enabled
219
- if (s.enabled !== undefined && typeof s.enabled !== 'boolean') {
220
- throw new RoutineValidationError('Schedule "enabled" must be a boolean', { source, got: s.enabled });
221
- }
222
-
223
- // Validate retries
224
- if (s.retries !== undefined) {
225
- if (typeof s.retries !== 'number' || !Number.isInteger(s.retries) || s.retries < 0) {
226
- throw new RoutineValidationError('Schedule "retries" must be a non-negative integer', { source, got: s.retries });
227
- }
228
- }
229
-
230
- // Validate retryDelayMs
231
- if (s.retryDelayMs !== undefined) {
232
- if (typeof s.retryDelayMs !== 'number' || !Number.isInteger(s.retryDelayMs) || s.retryDelayMs < 0) {
233
- throw new RoutineValidationError('Schedule "retryDelayMs" must be a non-negative integer', { source, got: s.retryDelayMs });
234
- }
235
- }
236
-
237
- // Validate concurrency
238
- if (s.concurrency !== undefined) {
239
- if (s.concurrency !== 'skip' && s.concurrency !== 'queue') {
240
- throw new RoutineValidationError('Schedule "concurrency" must be "skip" or "queue"', { source, got: s.concurrency });
241
- }
242
- }
243
-
244
- return s as RoutineSchedule;
245
- }
246
-
247
- function validateRoutineDefinition(value: unknown, source?: string): RoutineDefinition {
248
- if (!value || typeof value !== 'object') {
249
- throw new RoutineValidationError('Routine must be an object', { source });
250
- }
251
-
252
- const obj = value as Record<string, unknown>;
253
-
254
- if (obj.version !== 1) {
255
- throw new RoutineValidationError('Invalid or missing "version" (must be 1)', { source, got: obj.version });
256
- }
257
-
258
- if (typeof obj.name !== 'string' || obj.name.length === 0) {
259
- throw new RoutineValidationError('Invalid or missing "name"', { source, got: obj.name });
260
- }
261
-
262
- // Validate schedule if present
263
- if (obj.schedule !== undefined) {
264
- validateScheduleConfig(obj.schedule, source);
265
- }
266
-
267
- if (!Array.isArray(obj.steps)) {
268
- throw new RoutineValidationError('Invalid or missing "steps" (must be an array)', { source });
269
- }
270
-
271
- const steps = obj.steps as unknown[];
272
- const ids = new Set<string>();
273
-
274
- for (const [idx, step] of steps.entries()) {
275
- if (!step || typeof step !== 'object') {
276
- throw new RoutineValidationError('Invalid step (must be an object)', { source, index: idx });
277
- }
278
-
279
- const s = step as Record<string, unknown>;
280
-
281
- if (typeof s.id !== 'string' || s.id.length === 0) {
282
- throw new RoutineValidationError('Step missing "id"', { source, index: idx });
283
- }
284
-
285
- if (ids.has(s.id)) {
286
- throw new RoutineValidationError(`Duplicate step id: ${s.id}`, { source, id: s.id });
287
- }
288
- ids.add(s.id);
289
-
290
- if (typeof s.type !== 'string' || !['cli4ai', 'set', 'exec'].includes(s.type)) {
291
- throw new RoutineValidationError(`Invalid step type for "${s.id}"`, { source, id: s.id, got: s.type });
292
- }
293
-
294
- if (s.timeout !== undefined && typeof s.timeout !== 'number') {
295
- throw new RoutineValidationError(`Invalid timeout for "${s.id}" (must be number ms)`, { source, id: s.id, got: s.timeout });
296
- }
297
-
298
- if (s.continueOnError !== undefined && typeof s.continueOnError !== 'boolean') {
299
- throw new RoutineValidationError(`Invalid continueOnError for "${s.id}" (must be boolean)`, { source, id: s.id, got: s.continueOnError });
300
- }
301
-
302
- if (s.type === 'cli4ai') {
303
- if (typeof s.package !== 'string' || s.package.length === 0) {
304
- throw new RoutineValidationError(`Step "${s.id}" missing "package"`, { source, id: s.id });
305
- }
306
- if (s.command !== undefined && typeof s.command !== 'string') {
307
- throw new RoutineValidationError(`Step "${s.id}" has invalid "command"`, { source, id: s.id });
308
- }
309
- if (s.args !== undefined && (!Array.isArray(s.args) || s.args.some(a => typeof a !== 'string'))) {
310
- throw new RoutineValidationError(`Step "${s.id}" has invalid "args" (must be string array)`, { source, id: s.id });
311
- }
312
- if (s.env !== undefined) {
313
- if (typeof s.env !== 'object' || s.env === null) {
314
- throw new RoutineValidationError(`Step "${s.id}" has invalid "env" (must be object)`, { source, id: s.id });
315
- }
316
- for (const [k, v] of Object.entries(s.env as Record<string, unknown>)) {
317
- if (typeof v !== 'string') {
318
- throw new RoutineValidationError(`Step "${s.id}" env.${k} must be a string template`, { source, id: s.id });
319
- }
320
- }
321
- }
322
- if (s.stdin !== undefined && typeof s.stdin !== 'string') {
323
- throw new RoutineValidationError(`Step "${s.id}" has invalid "stdin"`, { source, id: s.id });
324
- }
325
- if (s.capture !== undefined && !['inherit', 'text', 'json'].includes(String(s.capture))) {
326
- throw new RoutineValidationError(`Step "${s.id}" has invalid "capture"`, { source, id: s.id, got: s.capture });
327
- }
328
- if (s.remote !== undefined && typeof s.remote !== 'string') {
329
- throw new RoutineValidationError(`Step "${s.id}" has invalid "remote" (must be string)`, { source, id: s.id, got: s.remote });
330
- }
331
- }
332
-
333
- if (s.type === 'set') {
334
- if (typeof s.vars !== 'object' || s.vars === null) {
335
- throw new RoutineValidationError(`Step "${s.id}" missing "vars"`, { source, id: s.id });
336
- }
337
- for (const [k, v] of Object.entries(s.vars as Record<string, unknown>)) {
338
- if (typeof v !== 'string') {
339
- throw new RoutineValidationError(`Step "${s.id}" vars.${k} must be a string template`, { source, id: s.id });
340
- }
341
- }
342
- }
343
-
344
- if (s.type === 'exec') {
345
- if (typeof s.cmd !== 'string' || s.cmd.length === 0) {
346
- throw new RoutineValidationError(`Step "${s.id}" missing "cmd"`, { source, id: s.id });
347
- }
348
- if (s.args !== undefined && (!Array.isArray(s.args) || s.args.some(a => typeof a !== 'string'))) {
349
- throw new RoutineValidationError(`Step "${s.id}" has invalid "args" (must be string array)`, { source, id: s.id });
350
- }
351
- if (s.env !== undefined) {
352
- if (typeof s.env !== 'object' || s.env === null) {
353
- throw new RoutineValidationError(`Step "${s.id}" has invalid "env" (must be object)`, { source, id: s.id });
354
- }
355
- for (const [k, v] of Object.entries(s.env as Record<string, unknown>)) {
356
- if (typeof v !== 'string') {
357
- throw new RoutineValidationError(`Step "${s.id}" env.${k} must be a string template`, { source, id: s.id });
358
- }
359
- }
360
- }
361
- if (s.stdin !== undefined && typeof s.stdin !== 'string') {
362
- throw new RoutineValidationError(`Step "${s.id}" has invalid "stdin"`, { source, id: s.id });
363
- }
364
- if (s.capture !== undefined && !['text', 'json'].includes(String(s.capture))) {
365
- throw new RoutineValidationError(`Step "${s.id}" has invalid "capture"`, { source, id: s.id, got: s.capture });
366
- }
367
- }
368
- }
369
-
370
- return obj as unknown as RoutineDefinition;
371
- }
372
-
373
- function getPath(root: unknown, segments: string[]): unknown {
374
- let current: unknown = root;
375
- for (const seg of segments) {
376
- if (current === null || current === undefined) return undefined;
377
-
378
- if (Array.isArray(current)) {
379
- const idx = Number(seg);
380
- if (!Number.isInteger(idx)) return undefined;
381
- current = current[idx];
382
- continue;
383
- }
384
-
385
- if (typeof current !== 'object') return undefined;
386
-
387
- current = (current as Record<string, unknown>)[seg];
388
- }
389
- return current;
390
- }
391
-
392
- function renderTemplateString(
393
- template: string,
394
- ctx: Record<string, unknown>,
395
- meta: { stepId?: string; field?: string; strict?: boolean }
396
- ): unknown {
397
- const regex = /\{\{\s*([^}]+?)\s*\}\}/g;
398
- const matches = [...template.matchAll(regex)];
399
- if (matches.length === 0) return template;
400
-
401
- const resolveExpr = (expr: string): unknown => {
402
- const parts = expr.split('.').map(s => s.trim()).filter(Boolean);
403
- const value = getPath(ctx, parts);
404
- if (value === undefined) {
405
- if (meta.strict === false) {
406
- return `{{${expr}}}`;
407
- }
408
- throw new RoutineTemplateError(`Template error${meta.stepId ? ` in step "${meta.stepId}"` : ''}: path "${expr}" not found`, {
409
- step: meta.stepId,
410
- field: meta.field,
411
- path: expr
412
- });
413
- }
414
- return value;
415
- };
416
-
417
- // If the template is exactly a single {{expr}}, return the raw value.
418
- if (matches.length === 1 && matches[0].index === 0 && matches[0][0].length === template.length) {
419
- return resolveExpr(matches[0][1]);
420
- }
421
-
422
- let out = '';
423
- let lastIndex = 0;
424
- for (const m of matches) {
425
- const full = m[0];
426
- const expr = m[1];
427
- const idx = m.index ?? 0;
428
- out += template.slice(lastIndex, idx);
429
- const value = resolveExpr(expr);
430
- if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
431
- out += String(value);
432
- } else {
433
- out += JSON.stringify(value);
434
- }
435
- lastIndex = idx + full.length;
436
- }
437
- out += template.slice(lastIndex);
438
- return out;
439
- }
440
-
441
- function renderString(value: string, ctx: Record<string, unknown>, meta: { stepId?: string; field?: string; strict?: boolean }): string {
442
- const rendered = renderTemplateString(value, ctx, meta);
443
- if (rendered === undefined || rendered === null) {
444
- throw new RoutineTemplateError(`Template error${meta.stepId ? ` in step "${meta.stepId}"` : ''}: "${value}" resolved to empty`, {
445
- step: meta.stepId,
446
- field: meta.field
447
- });
448
- }
449
- if (typeof rendered === 'string') return rendered;
450
- if (typeof rendered === 'number' || typeof rendered === 'boolean') return String(rendered);
451
- try {
452
- return JSON.stringify(rendered);
453
- } catch {
454
- throw new RoutineTemplateError(`Template error${meta.stepId ? ` in step "${meta.stepId}"` : ''}: "${value}" resolved to non-serializable value`, {
455
- step: meta.stepId,
456
- field: meta.field
457
- });
458
- }
459
- }
460
-
461
- function renderScalarString(value: string, ctx: Record<string, unknown>, meta: { stepId?: string; field?: string; strict?: boolean }): string {
462
- const rendered = renderTemplateString(value, ctx, meta);
463
- if (rendered === undefined || rendered === null) {
464
- throw new RoutineTemplateError(`Template error${meta.stepId ? ` in step "${meta.stepId}"` : ''}: "${value}" resolved to empty`, {
465
- step: meta.stepId,
466
- field: meta.field
467
- });
468
- }
469
- if (typeof rendered === 'string' || typeof rendered === 'number' || typeof rendered === 'boolean') {
470
- return String(rendered);
471
- }
472
- throw new RoutineTemplateError(
473
- `Template error${meta.stepId ? ` in step "${meta.stepId}"` : ''}: "${value}" did not resolve to a string`,
474
- {
475
- step: meta.stepId,
476
- field: meta.field,
477
- gotType: Array.isArray(rendered) ? 'array' : typeof rendered
478
- }
479
- );
480
- }
481
-
482
- function renderStringArray(values: string[] | undefined, ctx: Record<string, unknown>, meta: { stepId?: string; field?: string; strict?: boolean }): string[] {
483
- if (!values) return [];
484
- return values.map((v, i) => renderString(v, ctx, { ...meta, field: `${meta.field ?? 'args'}.${i}` }));
485
- }
486
-
487
- function renderEnv(env: Record<string, string> | undefined, ctx: Record<string, unknown>, meta: { stepId?: string; field?: string; strict?: boolean }): Record<string, string> {
488
- if (!env) return {};
489
- const out: Record<string, string> = {};
490
- for (const [k, v] of Object.entries(env)) {
491
- out[k] = renderString(v, ctx, { ...meta, field: `${meta.field ?? 'env'}.${k}` });
492
- }
493
- return out;
494
- }
495
-
496
- function renderTemplateValue(value: unknown, ctx: Record<string, unknown>, meta: { stepId?: string; field?: string; strict?: boolean }): unknown {
497
- if (typeof value === 'string') {
498
- return renderTemplateString(value, ctx, meta);
499
- }
500
- if (Array.isArray(value)) {
501
- return value.map((item, idx) => renderTemplateValue(item, ctx, { ...meta, field: `${meta.field ?? 'value'}.${idx}` }));
502
- }
503
- if (value && typeof value === 'object') {
504
- const out: Record<string, unknown> = {};
505
- for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
506
- out[k] = renderTemplateValue(v, ctx, { ...meta, field: `${meta.field ?? 'value'}.${k}` });
507
- }
508
- return out;
509
- }
510
- return value;
511
- }
512
-
513
- function collectStream(stream: NodeJS.ReadableStream): Promise<string> {
514
- return new Promise((resolve, reject) => {
515
- const chunks: Buffer[] = [];
516
- stream.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk))));
517
- stream.on('error', reject);
518
- stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
519
- });
520
- }
521
-
522
- async function runExecStep(step: RoutineExecStep, ctx: Record<string, unknown>, invocationDir: string): Promise<StepRunResult> {
523
- const startTime = Date.now();
524
-
525
- const cmd = renderScalarString(step.cmd, ctx, { stepId: step.id, field: 'cmd' });
526
- const args = renderStringArray(step.args, ctx, { stepId: step.id, field: 'args' });
527
- const env = renderEnv(step.env, ctx, { stepId: step.id, field: 'env' });
528
- const stdin = step.stdin !== undefined ? renderString(step.stdin, ctx, { stepId: step.id, field: 'stdin' }) : undefined;
529
-
530
- const child = spawn(cmd, args, {
531
- cwd: invocationDir,
532
- stdio: ['pipe', 'pipe', 'pipe'],
533
- env: {
534
- ...process.env,
535
- ...(env ?? {})
536
- }
537
- });
538
-
539
- if (stdin !== undefined && child.stdin) {
540
- child.stdin.write(stdin);
541
- }
542
- if (child.stdin) child.stdin.end();
543
-
544
- const stdoutPromise = child.stdout ? collectStream(child.stdout) : Promise.resolve('');
545
- const stderrPromise = child.stderr ? collectStream(child.stderr) : Promise.resolve('');
546
-
547
- if (child.stderr) {
548
- child.stderr.on('data', (chunk) => process.stderr.write(chunk));
549
- }
550
-
551
- let timeout: NodeJS.Timeout | undefined;
552
- if (step.timeout && step.timeout > 0) {
553
- timeout = setTimeout(() => {
554
- try {
555
- child.kill('SIGTERM');
556
- } catch {}
557
- setTimeout(() => {
558
- try {
559
- child.kill('SIGKILL');
560
- } catch {}
561
- }, 250);
562
- }, step.timeout);
563
- }
564
-
565
- const exitCode = await new Promise<number>((resolve, reject) => {
566
- child.on('close', (code) => resolve(code ?? 0));
567
- child.on('error', (err) => reject(new ExecuteToolError('API_ERROR', `Failed to execute: ${err.message}`)));
568
- }).finally(() => {
569
- if (timeout) clearTimeout(timeout);
570
- });
571
-
572
- const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]);
573
-
574
- const capture = step.capture ?? 'text';
575
- let json: unknown | undefined;
576
- if (capture === 'json') {
577
- try {
578
- json = JSON.parse(stdout);
579
- } catch {
580
- throw new RoutineTemplateError(`JSON parse error in step "${step.id}": stdout is not valid JSON`, {
581
- step: step.id,
582
- stdout: stdout.slice(0, 200)
583
- });
584
- }
585
- }
586
-
587
- return {
588
- id: step.id,
589
- type: step.type,
590
- status: exitCode === 0 ? 'success' : 'failed',
591
- exitCode,
592
- durationMs: Date.now() - startTime,
593
- stdout,
594
- stderr,
595
- json
596
- };
597
- }
598
-
599
- export async function dryRunRoutine(def: RoutineDefinition, vars: Record<string, string>, invocationDir: string): Promise<RoutineDryRunPlan> {
600
- const ctxVars: Record<string, unknown> = {};
601
-
602
- for (const [k, v] of Object.entries(def.vars ?? {})) {
603
- if (v.default !== undefined) ctxVars[k] = v.default;
604
- }
605
- for (const [k, v] of Object.entries(vars)) ctxVars[k] = v;
606
-
607
- const ctx: Record<string, unknown> = {
608
- vars: ctxVars,
609
- steps: {}
610
- };
611
-
612
- const steps: RoutineDryRunStep[] = [];
613
-
614
- for (const step of def.steps) {
615
- if (step.type === 'set') {
616
- const renderedVars: Record<string, unknown> = {};
617
- for (const [k, v] of Object.entries(step.vars)) {
618
- renderedVars[k] = renderTemplateString(v, ctx, { stepId: step.id, field: `vars.${k}`, strict: false });
619
- }
620
- // Apply to context for subsequent steps
621
- for (const [k, v] of Object.entries(renderedVars)) ctxVars[k] = v;
622
-
623
- steps.push({ id: step.id, type: step.type, rendered: { vars: renderedVars } });
624
- continue;
625
- }
626
-
627
- if (step.type === 'cli4ai') {
628
- const rendered = {
629
- package: renderScalarString(step.package, ctx, { stepId: step.id, field: 'package', strict: false }),
630
- command: step.command ? renderScalarString(step.command, ctx, { stepId: step.id, field: 'command', strict: false }) : undefined,
631
- args: renderStringArray(step.args, ctx, { stepId: step.id, field: 'args', strict: false }),
632
- env: renderEnv(step.env, ctx, { stepId: step.id, field: 'env', strict: false }),
633
- stdin: step.stdin ? renderString(step.stdin, ctx, { stepId: step.id, field: 'stdin', strict: false }) : undefined,
634
- timeout: step.timeout
635
- };
636
- steps.push({ id: step.id, type: step.type, rendered });
637
- continue;
638
- }
639
-
640
- if (step.type === 'exec') {
641
- const rendered = {
642
- cmd: renderScalarString(step.cmd, ctx, { stepId: step.id, field: 'cmd', strict: false }),
643
- args: renderStringArray(step.args, ctx, { stepId: step.id, field: 'args', strict: false }),
644
- env: renderEnv(step.env, ctx, { stepId: step.id, field: 'env', strict: false }),
645
- stdin: step.stdin ? renderString(step.stdin, ctx, { stepId: step.id, field: 'stdin', strict: false }) : undefined,
646
- timeout: step.timeout
647
- };
648
- steps.push({ id: step.id, type: step.type, rendered });
649
- continue;
650
- }
651
- }
652
-
653
- const result = def.result !== undefined ? renderTemplateValue(def.result, ctx, { field: 'result', strict: false }) : undefined;
654
-
655
- return {
656
- routine: def.name,
657
- version: def.version,
658
- vars: ctxVars,
659
- steps,
660
- result
661
- };
662
- }
663
-
664
- export async function runRoutine(def: RoutineDefinition, vars: Record<string, string>, invocationDir: string): Promise<RoutineRunSummary> {
665
- const startTime = Date.now();
666
-
667
- const ctxVars: Record<string, unknown> = {};
668
- for (const [k, v] of Object.entries(def.vars ?? {})) {
669
- if (v.default !== undefined) ctxVars[k] = v.default;
670
- }
671
- for (const [k, v] of Object.entries(vars)) ctxVars[k] = v;
672
-
673
- const stepsById: Record<string, StepRunResult> = {};
674
-
675
- const ctx: Record<string, unknown> = {
676
- vars: ctxVars,
677
- steps: stepsById
678
- };
679
-
680
- const steps: StepRunResult[] = [];
681
-
682
- for (const step of def.steps) {
683
- if (step.type === 'set') {
684
- const assigned: Record<string, unknown> = {};
685
- for (const [k, v] of Object.entries(step.vars)) {
686
- assigned[k] = renderTemplateString(v, ctx, { stepId: step.id, field: `vars.${k}` });
687
- }
688
- for (const [k, v] of Object.entries(assigned)) ctxVars[k] = v;
689
-
690
- const res: StepRunResult = {
691
- id: step.id,
692
- type: step.type,
693
- status: 'success',
694
- exitCode: 0,
695
- durationMs: 0,
696
- json: assigned
697
- };
698
- stepsById[step.id] = res;
699
- steps.push(res);
700
- continue;
701
- }
702
-
703
- if (step.type === 'exec') {
704
- try {
705
- const res = await runExecStep(step, ctx, invocationDir);
706
- stepsById[step.id] = res;
707
- steps.push(res);
708
-
709
- if (res.exitCode !== 0 && !step.continueOnError) {
710
- return finalizeFailure(def, ctxVars, steps, Date.now() - startTime, res.exitCode ?? 1);
711
- }
712
- continue;
713
- } catch (err) {
714
- const error = normalizeError(err);
715
- const res: StepRunResult = { id: step.id, type: step.type, status: 'failed', exitCode: 1, error };
716
- stepsById[step.id] = res;
717
- steps.push(res);
718
- if (!step.continueOnError) {
719
- return finalizeFailure(def, ctxVars, steps, Date.now() - startTime, 1);
720
- }
721
- continue;
722
- }
723
- }
724
-
725
- if (step.type === 'cli4ai') {
726
- const pkg = renderScalarString(step.package, ctx, { stepId: step.id, field: 'package' });
727
- const cmd = step.command !== undefined ? renderScalarString(step.command, ctx, { stepId: step.id, field: 'command' }) : undefined;
728
- const args = renderStringArray(step.args, ctx, { stepId: step.id, field: 'args' });
729
- const env = renderEnv(step.env, ctx, { stepId: step.id, field: 'env' });
730
- const stdin = step.stdin !== undefined ? renderString(step.stdin, ctx, { stepId: step.id, field: 'stdin' }) : undefined;
731
- const remoteName = step.remote !== undefined ? renderScalarString(step.remote, ctx, { stepId: step.id, field: 'remote' }) : undefined;
732
-
733
- const capture = step.capture ?? 'text';
734
-
735
- try {
736
- let execResult: { exitCode: number; durationMs: number; stdout?: string; stderr?: string };
737
-
738
- if (remoteName) {
739
- // Remote execution
740
- const remote = getRemote(remoteName);
741
- if (!remote) {
742
- throw new ExecuteToolError('NOT_FOUND', `Remote "${remoteName}" not found`, {
743
- step: step.id,
744
- hint: 'Use "cli4ai remotes add <name> <url>" to configure a remote'
745
- });
746
- }
747
-
748
- const remoteRes = await remoteRunTool(remoteName, {
749
- package: pkg,
750
- command: cmd,
751
- args,
752
- env: Object.keys(env).length > 0 ? env : undefined,
753
- stdin,
754
- timeout: step.timeout
755
- });
756
-
757
- execResult = {
758
- exitCode: remoteRes.exitCode,
759
- durationMs: remoteRes.durationMs,
760
- stdout: remoteRes.stdout,
761
- stderr: remoteRes.stderr
762
- };
763
-
764
- // Log stderr from remote
765
- if (remoteRes.stderr) {
766
- process.stderr.write(remoteRes.stderr);
767
- }
768
-
769
- // Handle remote-level errors
770
- if (remoteRes.error && !remoteRes.success) {
771
- throw new ExecuteToolError(
772
- remoteRes.error.code,
773
- remoteRes.error.message,
774
- remoteRes.error.details
775
- );
776
- }
777
- } else {
778
- // Local execution
779
- const localRes = await executeTool({
780
- packageName: pkg,
781
- command: cmd,
782
- args,
783
- cwd: invocationDir,
784
- env,
785
- stdin,
786
- capture: 'pipe',
787
- timeoutMs: step.timeout,
788
- teeStderr: true
789
- });
790
-
791
- execResult = {
792
- exitCode: localRes.exitCode,
793
- durationMs: localRes.durationMs,
794
- stdout: localRes.stdout,
795
- stderr: localRes.stderr
796
- };
797
- }
798
-
799
- const res: StepRunResult = {
800
- id: step.id,
801
- type: step.type,
802
- status: execResult.exitCode === 0 ? 'success' : 'failed',
803
- exitCode: execResult.exitCode,
804
- durationMs: execResult.durationMs,
805
- stdout: execResult.stdout,
806
- stderr: execResult.stderr
807
- };
808
-
809
- if (capture === 'json') {
810
- try {
811
- res.json = execResult.stdout ? JSON.parse(execResult.stdout) : null;
812
- } catch {
813
- throw new RoutineTemplateError(`JSON parse error in step "${step.id}": stdout is not valid JSON`, {
814
- step: step.id,
815
- stdout: (execResult.stdout ?? '').slice(0, 200)
816
- });
817
- }
818
- }
819
-
820
- stepsById[step.id] = res;
821
- steps.push(res);
822
-
823
- if (execResult.exitCode !== 0 && !step.continueOnError) {
824
- return finalizeFailure(def, ctxVars, steps, Date.now() - startTime, execResult.exitCode);
825
- }
826
- continue;
827
- } catch (err) {
828
- const error = normalizeError(err);
829
- const res: StepRunResult = {
830
- id: step.id,
831
- type: step.type,
832
- status: 'failed',
833
- exitCode: error.code === 'INTEGRITY_ERROR' ? 1 : 1,
834
- error
835
- };
836
- stepsById[step.id] = res;
837
- steps.push(res);
838
- if (!step.continueOnError) {
839
- return finalizeFailure(def, ctxVars, steps, Date.now() - startTime, 1);
840
- }
841
- continue;
842
- }
843
- }
844
- }
845
-
846
- const result = def.result !== undefined ? renderTemplateValue(def.result, ctx, { field: 'result' }) : undefined;
847
-
848
- return {
849
- routine: def.name,
850
- version: def.version,
851
- status: 'success',
852
- exitCode: 0,
853
- durationMs: Date.now() - startTime,
854
- vars: ctxVars,
855
- steps,
856
- result
857
- };
858
- }
859
-
860
- function normalizeError(err: unknown): { code: string; message: string; details?: Record<string, unknown> } {
861
- if (err instanceof ExecuteToolError) {
862
- return { code: err.code, message: err.message, details: err.details };
863
- }
864
- if (err instanceof RemoteConnectionError) {
865
- return { code: 'NETWORK_ERROR', message: err.message, details: { remote: err.remoteName, url: err.url } };
866
- }
867
- if (err instanceof RemoteApiError) {
868
- return { code: err.code, message: err.message, details: err.details };
869
- }
870
- if (err instanceof RoutineTemplateError) {
871
- return { code: 'INVALID_INPUT', message: err.message, details: err.details };
872
- }
873
- if (err instanceof RoutineValidationError) {
874
- return { code: 'INVALID_INPUT', message: err.message, details: err.details };
875
- }
876
- if (err instanceof RoutineParseError) {
877
- return { code: 'PARSE_ERROR', message: err.message, details: { path: err.path } };
878
- }
879
- if (err instanceof Error) {
880
- return { code: 'API_ERROR', message: err.message };
881
- }
882
- return { code: 'API_ERROR', message: String(err) };
883
- }
884
-
885
- function finalizeFailure(def: RoutineDefinition, vars: Record<string, unknown>, steps: StepRunResult[], durationMs: number, exitCode: number): RoutineRunSummary {
886
- return {
887
- routine: def.name,
888
- version: def.version,
889
- status: 'failed',
890
- exitCode,
891
- durationMs,
892
- vars,
893
- steps
894
- };
895
- }