keystone-cli 0.1.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.
- package/README.md +136 -0
- package/logo.png +0 -0
- package/package.json +45 -0
- package/src/cli.ts +775 -0
- package/src/db/workflow-db.test.ts +99 -0
- package/src/db/workflow-db.ts +265 -0
- package/src/expression/evaluator.test.ts +247 -0
- package/src/expression/evaluator.ts +517 -0
- package/src/parser/agent-parser.test.ts +123 -0
- package/src/parser/agent-parser.ts +59 -0
- package/src/parser/config-schema.ts +54 -0
- package/src/parser/schema.ts +157 -0
- package/src/parser/workflow-parser.test.ts +212 -0
- package/src/parser/workflow-parser.ts +228 -0
- package/src/runner/llm-adapter.test.ts +329 -0
- package/src/runner/llm-adapter.ts +306 -0
- package/src/runner/llm-executor.test.ts +537 -0
- package/src/runner/llm-executor.ts +256 -0
- package/src/runner/mcp-client.test.ts +122 -0
- package/src/runner/mcp-client.ts +123 -0
- package/src/runner/mcp-manager.test.ts +143 -0
- package/src/runner/mcp-manager.ts +85 -0
- package/src/runner/mcp-server.test.ts +242 -0
- package/src/runner/mcp-server.ts +436 -0
- package/src/runner/retry.test.ts +52 -0
- package/src/runner/retry.ts +58 -0
- package/src/runner/shell-executor.test.ts +123 -0
- package/src/runner/shell-executor.ts +166 -0
- package/src/runner/step-executor.test.ts +465 -0
- package/src/runner/step-executor.ts +354 -0
- package/src/runner/timeout.test.ts +20 -0
- package/src/runner/timeout.ts +30 -0
- package/src/runner/tool-integration.test.ts +198 -0
- package/src/runner/workflow-runner.test.ts +358 -0
- package/src/runner/workflow-runner.ts +955 -0
- package/src/ui/dashboard.tsx +165 -0
- package/src/utils/auth-manager.test.ts +152 -0
- package/src/utils/auth-manager.ts +88 -0
- package/src/utils/config-loader.test.ts +52 -0
- package/src/utils/config-loader.ts +85 -0
- package/src/utils/mermaid.test.ts +51 -0
- package/src/utils/mermaid.ts +87 -0
- package/src/utils/redactor.test.ts +66 -0
- package/src/utils/redactor.ts +60 -0
- package/src/utils/workflow-registry.test.ts +108 -0
- package/src/utils/workflow-registry.ts +121 -0
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
import jsepArrow from '@jsep-plugin/arrow';
|
|
2
|
+
import jsepObject from '@jsep-plugin/object';
|
|
3
|
+
import jsep from 'jsep';
|
|
4
|
+
import { escapeShellArg } from '../runner/shell-executor.ts';
|
|
5
|
+
|
|
6
|
+
// Register plugins
|
|
7
|
+
jsep.plugins.register(jsepArrow);
|
|
8
|
+
jsep.plugins.register(jsepObject);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Expression evaluator for ${{ }} syntax
|
|
12
|
+
* Supports:
|
|
13
|
+
* - inputs.field
|
|
14
|
+
* - secrets.KEY
|
|
15
|
+
* - steps.step_id.output
|
|
16
|
+
* - steps.step_id.outputs.field
|
|
17
|
+
* - item (for foreach)
|
|
18
|
+
* - Basic JS expressions (arithmetic, comparisons, logical operators)
|
|
19
|
+
* - Array access, method calls (map, filter, every, etc.)
|
|
20
|
+
* - Ternary operator
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ SECURITY:
|
|
23
|
+
* This evaluator uses AST-based parsing (jsep) to safely evaluate expressions
|
|
24
|
+
* without executing arbitrary code. Only whitelisted operations are allowed.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export interface ExpressionContext {
|
|
28
|
+
inputs?: Record<string, unknown>;
|
|
29
|
+
secrets?: Record<string, string>;
|
|
30
|
+
steps?: Record<string, { output?: unknown; outputs?: Record<string, unknown>; status?: string }>;
|
|
31
|
+
item?: unknown;
|
|
32
|
+
index?: number;
|
|
33
|
+
env?: Record<string, string>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type ASTNode = jsep.Expression;
|
|
37
|
+
|
|
38
|
+
interface ArrowFunctionExpression extends jsep.Expression {
|
|
39
|
+
type: 'ArrowFunctionExpression';
|
|
40
|
+
params: jsep.Identifier[];
|
|
41
|
+
body: jsep.Expression;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface ObjectProperty extends jsep.Expression {
|
|
45
|
+
type: 'Property';
|
|
46
|
+
key: jsep.Expression;
|
|
47
|
+
value: jsep.Expression;
|
|
48
|
+
computed: boolean;
|
|
49
|
+
shorthand: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface ObjectExpression extends jsep.Expression {
|
|
53
|
+
type: 'ObjectExpression';
|
|
54
|
+
properties: ObjectProperty[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class ExpressionEvaluator {
|
|
58
|
+
/**
|
|
59
|
+
* Evaluate a string that may contain ${{ }} expressions
|
|
60
|
+
*/
|
|
61
|
+
static evaluate(template: string, context: ExpressionContext): unknown {
|
|
62
|
+
// Improved regex that handles nested braces (up to 2 levels of nesting)
|
|
63
|
+
// Matches ${{ ... }} and allows { ... } inside for object literals and arrow functions
|
|
64
|
+
const expressionRegex = /\$\{\{(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*\}\}/g;
|
|
65
|
+
|
|
66
|
+
// If the entire string is a single expression, return the evaluated value directly
|
|
67
|
+
const singleExprMatch = template.match(
|
|
68
|
+
/^\s*\$\{\{(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*\}\}\s*$/
|
|
69
|
+
);
|
|
70
|
+
if (singleExprMatch) {
|
|
71
|
+
// Extract the expression content between ${{ and }}
|
|
72
|
+
const expr = singleExprMatch[0].replace(/^\s*\$\{\{\s*|\s*\}\}\s*$/g, '');
|
|
73
|
+
return ExpressionEvaluator.evaluateExpression(expr, context);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Otherwise, replace all expressions in the string
|
|
77
|
+
return template.replace(expressionRegex, (match) => {
|
|
78
|
+
// Extract the expression content between ${{ and }}
|
|
79
|
+
const expr = match.replace(/^\$\{\{\s*|\s*\}\}$/g, '');
|
|
80
|
+
const result = ExpressionEvaluator.evaluateExpression(expr, context);
|
|
81
|
+
return String(result);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Evaluate a single expression (without the ${{ }} wrapper)
|
|
87
|
+
* This is public to support transform expressions in shell steps
|
|
88
|
+
*/
|
|
89
|
+
static evaluateExpression(expr: string, context: ExpressionContext): unknown {
|
|
90
|
+
try {
|
|
91
|
+
const ast = jsep(expr);
|
|
92
|
+
return ExpressionEvaluator.evaluateNode(ast, context);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`Failed to evaluate expression "${expr}": ${error instanceof Error ? error.message : String(error)}`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Evaluate an AST node recursively
|
|
102
|
+
*/
|
|
103
|
+
private static evaluateNode(node: ASTNode, context: ExpressionContext): unknown {
|
|
104
|
+
switch (node.type) {
|
|
105
|
+
case 'Literal':
|
|
106
|
+
return (node as jsep.Literal).value;
|
|
107
|
+
|
|
108
|
+
case 'Identifier': {
|
|
109
|
+
const name = (node as jsep.Identifier).name;
|
|
110
|
+
|
|
111
|
+
// Safe global functions and values
|
|
112
|
+
const safeGlobals: Record<string, unknown> = {
|
|
113
|
+
Boolean: Boolean,
|
|
114
|
+
Number: Number,
|
|
115
|
+
String: String,
|
|
116
|
+
Array: Array,
|
|
117
|
+
Object: Object,
|
|
118
|
+
Math: Math,
|
|
119
|
+
Date: Date,
|
|
120
|
+
JSON: JSON,
|
|
121
|
+
parseInt: Number.parseInt,
|
|
122
|
+
parseFloat: Number.parseFloat,
|
|
123
|
+
isNaN: Number.isNaN,
|
|
124
|
+
isFinite: Number.isFinite,
|
|
125
|
+
undefined: undefined,
|
|
126
|
+
null: null,
|
|
127
|
+
NaN: Number.NaN,
|
|
128
|
+
Infinity: Number.Infinity,
|
|
129
|
+
true: true,
|
|
130
|
+
false: false,
|
|
131
|
+
escape: escapeShellArg, // Shell argument escaping for safe command execution
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Check safe globals first
|
|
135
|
+
if (name in safeGlobals) {
|
|
136
|
+
return safeGlobals[name];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Check if it's an arrow function parameter (stored directly in context)
|
|
140
|
+
const contextAsRecord = context as Record<string, unknown>;
|
|
141
|
+
if (name in contextAsRecord && contextAsRecord[name] !== undefined) {
|
|
142
|
+
return contextAsRecord[name];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Root context variables
|
|
146
|
+
const rootContext: Record<string, unknown> = {
|
|
147
|
+
inputs: context.inputs || {},
|
|
148
|
+
secrets: context.secrets || {},
|
|
149
|
+
steps: context.steps || {},
|
|
150
|
+
item: context.item,
|
|
151
|
+
index: context.index,
|
|
152
|
+
env: context.env || {},
|
|
153
|
+
stdout: contextAsRecord.stdout, // For transform expressions
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
if (name in rootContext && rootContext[name] !== undefined) {
|
|
157
|
+
return rootContext[name];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
throw new Error(`Undefined variable: ${name}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
case 'MemberExpression': {
|
|
164
|
+
const memberNode = node as jsep.MemberExpression;
|
|
165
|
+
const object = ExpressionEvaluator.evaluateNode(memberNode.object, context);
|
|
166
|
+
|
|
167
|
+
if (object === null || object === undefined) {
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let property: string | number;
|
|
172
|
+
if (memberNode.computed) {
|
|
173
|
+
// Computed access: obj[expr]
|
|
174
|
+
property = ExpressionEvaluator.evaluateNode(memberNode.property, context) as
|
|
175
|
+
| string
|
|
176
|
+
| number;
|
|
177
|
+
} else {
|
|
178
|
+
// Dot access: obj.prop
|
|
179
|
+
property = (memberNode.property as jsep.Identifier).name;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const propertyAsRecord = object as Record<string | number, unknown>;
|
|
183
|
+
|
|
184
|
+
// Security check for sensitive properties
|
|
185
|
+
const forbiddenProperties = ['constructor', '__proto__', 'prototype'];
|
|
186
|
+
if (typeof property === 'string' && forbiddenProperties.includes(property)) {
|
|
187
|
+
throw new Error(`Access to property ${property} is forbidden`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return propertyAsRecord[property];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
case 'BinaryExpression': {
|
|
194
|
+
const binaryNode = node as jsep.BinaryExpression;
|
|
195
|
+
const left = ExpressionEvaluator.evaluateNode(binaryNode.left, context);
|
|
196
|
+
|
|
197
|
+
// Short-circuit for logical operators that jsep might parse as BinaryExpression
|
|
198
|
+
if (binaryNode.operator === '&&') {
|
|
199
|
+
return left && ExpressionEvaluator.evaluateNode(binaryNode.right, context);
|
|
200
|
+
}
|
|
201
|
+
if (binaryNode.operator === '||') {
|
|
202
|
+
return left || ExpressionEvaluator.evaluateNode(binaryNode.right, context);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const right = ExpressionEvaluator.evaluateNode(binaryNode.right, context);
|
|
206
|
+
|
|
207
|
+
switch (binaryNode.operator) {
|
|
208
|
+
case '+':
|
|
209
|
+
return (left as number) + (right as number);
|
|
210
|
+
case '-':
|
|
211
|
+
return (left as number) - (right as number);
|
|
212
|
+
case '*':
|
|
213
|
+
return (left as number) * (right as number);
|
|
214
|
+
case '/':
|
|
215
|
+
return (left as number) / (right as number);
|
|
216
|
+
case '%':
|
|
217
|
+
return (left as number) % (right as number);
|
|
218
|
+
case '==':
|
|
219
|
+
return left === right;
|
|
220
|
+
case '===':
|
|
221
|
+
return left === right;
|
|
222
|
+
case '!=':
|
|
223
|
+
return left !== right;
|
|
224
|
+
case '!==':
|
|
225
|
+
return left !== right;
|
|
226
|
+
case '<':
|
|
227
|
+
return (left as number) < (right as number);
|
|
228
|
+
case '<=':
|
|
229
|
+
return (left as number) <= (right as number);
|
|
230
|
+
case '>':
|
|
231
|
+
return (left as number) > (right as number);
|
|
232
|
+
case '>=':
|
|
233
|
+
return (left as number) >= (right as number);
|
|
234
|
+
default:
|
|
235
|
+
throw new Error(`Unsupported binary operator: ${binaryNode.operator}`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
case 'UnaryExpression': {
|
|
240
|
+
const unaryNode = node as jsep.UnaryExpression;
|
|
241
|
+
const argument = ExpressionEvaluator.evaluateNode(unaryNode.argument, context);
|
|
242
|
+
|
|
243
|
+
switch (unaryNode.operator) {
|
|
244
|
+
case '-':
|
|
245
|
+
return -(argument as number);
|
|
246
|
+
case '+':
|
|
247
|
+
return +(argument as number);
|
|
248
|
+
case '!':
|
|
249
|
+
return !argument;
|
|
250
|
+
default:
|
|
251
|
+
throw new Error(`Unsupported unary operator: ${unaryNode.operator}`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
case 'LogicalExpression': {
|
|
256
|
+
const logicalNode = node as jsep.LogicalExpression;
|
|
257
|
+
const left = ExpressionEvaluator.evaluateNode(logicalNode.left, context);
|
|
258
|
+
|
|
259
|
+
// Short-circuit evaluation
|
|
260
|
+
if (logicalNode.operator === '&&' && !left) {
|
|
261
|
+
return left;
|
|
262
|
+
}
|
|
263
|
+
if (logicalNode.operator === '||' && left) {
|
|
264
|
+
return left;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return ExpressionEvaluator.evaluateNode(logicalNode.right, context);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
case 'ConditionalExpression': {
|
|
271
|
+
const conditionalNode = node as jsep.ConditionalExpression;
|
|
272
|
+
const test = ExpressionEvaluator.evaluateNode(conditionalNode.test, context);
|
|
273
|
+
return test
|
|
274
|
+
? ExpressionEvaluator.evaluateNode(conditionalNode.consequent, context)
|
|
275
|
+
: ExpressionEvaluator.evaluateNode(conditionalNode.alternate, context);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
case 'ArrayExpression': {
|
|
279
|
+
const arrayNode = node as jsep.ArrayExpression;
|
|
280
|
+
return arrayNode.elements.map((elem) => ExpressionEvaluator.evaluateNode(elem, context));
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
case 'ObjectExpression': {
|
|
284
|
+
const objectNode = node as unknown as ObjectExpression;
|
|
285
|
+
const result: Record<string, unknown> = {};
|
|
286
|
+
for (const prop of objectNode.properties) {
|
|
287
|
+
const key =
|
|
288
|
+
prop.key.type === 'Identifier' && !prop.computed
|
|
289
|
+
? (prop.key as jsep.Identifier).name
|
|
290
|
+
: ExpressionEvaluator.evaluateNode(prop.key, context);
|
|
291
|
+
result[key as string] = ExpressionEvaluator.evaluateNode(prop.value, context);
|
|
292
|
+
}
|
|
293
|
+
return result;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
case 'CallExpression': {
|
|
297
|
+
const callNode = node as jsep.CallExpression;
|
|
298
|
+
|
|
299
|
+
// Evaluate the callee (could be a member expression like arr.map or an identifier like escape())
|
|
300
|
+
if (callNode.callee.type === 'MemberExpression') {
|
|
301
|
+
const memberNode = callNode.callee as jsep.MemberExpression;
|
|
302
|
+
const object = ExpressionEvaluator.evaluateNode(memberNode.object, context);
|
|
303
|
+
const methodName = (memberNode.property as jsep.Identifier).name;
|
|
304
|
+
|
|
305
|
+
// Evaluate arguments, handling arrow functions specially
|
|
306
|
+
const args = callNode.arguments.map((arg) => {
|
|
307
|
+
if (arg.type === 'ArrowFunctionExpression') {
|
|
308
|
+
return ExpressionEvaluator.createArrowFunction(
|
|
309
|
+
arg as ArrowFunctionExpression,
|
|
310
|
+
context
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
return ExpressionEvaluator.evaluateNode(arg, context);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// Allow only safe array/string methods
|
|
317
|
+
const safeMethods = [
|
|
318
|
+
'map',
|
|
319
|
+
'filter',
|
|
320
|
+
'reduce',
|
|
321
|
+
'every',
|
|
322
|
+
'some',
|
|
323
|
+
'find',
|
|
324
|
+
'findIndex',
|
|
325
|
+
'includes',
|
|
326
|
+
'indexOf',
|
|
327
|
+
'slice',
|
|
328
|
+
'concat',
|
|
329
|
+
'join',
|
|
330
|
+
'split',
|
|
331
|
+
'toLowerCase',
|
|
332
|
+
'toUpperCase',
|
|
333
|
+
'trim',
|
|
334
|
+
'startsWith',
|
|
335
|
+
'endsWith',
|
|
336
|
+
'replace',
|
|
337
|
+
'match',
|
|
338
|
+
'toString',
|
|
339
|
+
'length',
|
|
340
|
+
'max',
|
|
341
|
+
'min',
|
|
342
|
+
'abs',
|
|
343
|
+
'round',
|
|
344
|
+
'floor',
|
|
345
|
+
'ceil',
|
|
346
|
+
'stringify',
|
|
347
|
+
'parse',
|
|
348
|
+
'keys',
|
|
349
|
+
'values',
|
|
350
|
+
'entries',
|
|
351
|
+
];
|
|
352
|
+
|
|
353
|
+
if (!safeMethods.includes(methodName)) {
|
|
354
|
+
throw new Error(`Method ${methodName} is not allowed`);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// For methods that take callbacks (map, filter, etc.), we need special handling
|
|
358
|
+
// Since we can't pass AST nodes directly, we'll handle the most common patterns
|
|
359
|
+
if (object && typeof (object as Record<string, unknown>)[methodName] === 'function') {
|
|
360
|
+
return (object as Record<string, unknown>)[methodName](...args);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
throw new Error(`Cannot call method ${methodName} on ${typeof object}`);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Handle standalone function calls (e.g., escape())
|
|
367
|
+
if (callNode.callee.type === 'Identifier') {
|
|
368
|
+
const functionName = (callNode.callee as jsep.Identifier).name;
|
|
369
|
+
|
|
370
|
+
// Get the function from safe globals or context
|
|
371
|
+
const func = ExpressionEvaluator.evaluateNode(callNode.callee, context);
|
|
372
|
+
|
|
373
|
+
if (typeof func !== 'function') {
|
|
374
|
+
throw new Error(`${functionName} is not a function`);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Evaluate arguments
|
|
378
|
+
const args = callNode.arguments.map((arg) => {
|
|
379
|
+
if (arg.type === 'ArrowFunctionExpression') {
|
|
380
|
+
return ExpressionEvaluator.createArrowFunction(
|
|
381
|
+
arg as ArrowFunctionExpression,
|
|
382
|
+
context
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
return ExpressionEvaluator.evaluateNode(arg, context);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
return func(...args);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
throw new Error('Only method calls and safe function calls are supported');
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
case 'ArrowFunctionExpression': {
|
|
395
|
+
// Arrow functions should be handled in the context of CallExpression
|
|
396
|
+
// If we reach here, it means they're being used outside of a method call
|
|
397
|
+
return ExpressionEvaluator.createArrowFunction(node as ArrowFunctionExpression, context);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
default:
|
|
401
|
+
throw new Error(`Unsupported expression type: ${node.type}`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Create a JavaScript function from an arrow function AST node
|
|
407
|
+
*/
|
|
408
|
+
private static createArrowFunction(
|
|
409
|
+
arrowNode: ArrowFunctionExpression,
|
|
410
|
+
context: ExpressionContext
|
|
411
|
+
): (...args: unknown[]) => unknown {
|
|
412
|
+
return (...args: unknown[]) => {
|
|
413
|
+
// Create a new context with arrow function parameters
|
|
414
|
+
const arrowContext = { ...context };
|
|
415
|
+
|
|
416
|
+
// Bind parameters to arguments
|
|
417
|
+
arrowNode.params.forEach((param, index) => {
|
|
418
|
+
const paramName = param.name;
|
|
419
|
+
// Store parameter values in the context at the root level
|
|
420
|
+
(arrowContext as Record<string, unknown>)[paramName] = args[index];
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// Evaluate the body with the new context
|
|
424
|
+
return ExpressionEvaluator.evaluateNode(arrowNode.body, arrowContext);
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Check if a string contains any expressions
|
|
430
|
+
*/
|
|
431
|
+
static hasExpression(str: string): boolean {
|
|
432
|
+
// Use same improved regex that handles nested braces (up to 2 levels)
|
|
433
|
+
return /\$\{\{(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*\}\}/.test(str);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Recursively evaluate all expressions in an object
|
|
438
|
+
*/
|
|
439
|
+
static evaluateObject(obj: unknown, context: ExpressionContext): unknown {
|
|
440
|
+
if (typeof obj === 'string') {
|
|
441
|
+
return ExpressionEvaluator.evaluate(obj, context);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (Array.isArray(obj)) {
|
|
445
|
+
return obj.map((item) => ExpressionEvaluator.evaluateObject(item, context));
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (obj !== null && typeof obj === 'object') {
|
|
449
|
+
const result: Record<string, unknown> = {};
|
|
450
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
451
|
+
result[key] = ExpressionEvaluator.evaluateObject(value, context);
|
|
452
|
+
}
|
|
453
|
+
return result;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return obj;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Extract step IDs that this template depends on
|
|
461
|
+
*/
|
|
462
|
+
static findStepDependencies(template: string): string[] {
|
|
463
|
+
const dependencies = new Set<string>();
|
|
464
|
+
const expressionRegex = /\$\{\{(?:[^{}]|\{(?:[^{}]|\{[^{}]*\})*\})*\}\}/g;
|
|
465
|
+
const matches = template.matchAll(expressionRegex);
|
|
466
|
+
|
|
467
|
+
for (const match of matches) {
|
|
468
|
+
const expr = match[0].replace(/^\$\{\{\s*|\s*\}\}$/g, '');
|
|
469
|
+
try {
|
|
470
|
+
const ast = jsep(expr);
|
|
471
|
+
ExpressionEvaluator.collectStepIds(ast, dependencies);
|
|
472
|
+
} catch {
|
|
473
|
+
// Ignore parse errors, they'll be handled at runtime
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return Array.from(dependencies);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Recursively find step IDs in an AST node
|
|
482
|
+
*/
|
|
483
|
+
private static collectStepIds(node: jsep.Expression, dependencies: Set<string>): void {
|
|
484
|
+
if (!node) return;
|
|
485
|
+
|
|
486
|
+
if (node.type === 'MemberExpression') {
|
|
487
|
+
const memberNode = node as jsep.MemberExpression;
|
|
488
|
+
if (
|
|
489
|
+
memberNode.object.type === 'Identifier' &&
|
|
490
|
+
(memberNode.object as jsep.Identifier).name === 'steps'
|
|
491
|
+
) {
|
|
492
|
+
if (memberNode.property.type === 'Identifier' && !memberNode.computed) {
|
|
493
|
+
dependencies.add((memberNode.property as jsep.Identifier).name);
|
|
494
|
+
} else if (memberNode.property.type === 'Literal' && memberNode.computed) {
|
|
495
|
+
dependencies.add(String((memberNode.property as jsep.Literal).value));
|
|
496
|
+
}
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Generic traversal
|
|
502
|
+
for (const key of Object.keys(node)) {
|
|
503
|
+
const child = (node as Record<string, unknown>)[key];
|
|
504
|
+
if (child && typeof child === 'object') {
|
|
505
|
+
if (Array.isArray(child)) {
|
|
506
|
+
for (const item of child) {
|
|
507
|
+
if (item && typeof (item as jsep.Expression).type === 'string') {
|
|
508
|
+
ExpressionEvaluator.collectStepIds(item as jsep.Expression, dependencies);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
} else if (typeof (child as jsep.Expression).type === 'string') {
|
|
512
|
+
ExpressionEvaluator.collectStepIds(child as jsep.Expression, dependencies);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { afterAll, describe, expect, it, spyOn } from 'bun:test';
|
|
2
|
+
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { parseAgent, resolveAgentPath } from './agent-parser';
|
|
6
|
+
|
|
7
|
+
describe('agent-parser', () => {
|
|
8
|
+
const tempDir = join(process.cwd(), 'temp-test-agents');
|
|
9
|
+
|
|
10
|
+
// Setup temp directory
|
|
11
|
+
try {
|
|
12
|
+
mkdirSync(tempDir, { recursive: true });
|
|
13
|
+
} catch (e) {}
|
|
14
|
+
|
|
15
|
+
afterAll(() => {
|
|
16
|
+
try {
|
|
17
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
18
|
+
} catch (e) {}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('parseAgent', () => {
|
|
22
|
+
it('should parse a valid agent markdown file', () => {
|
|
23
|
+
const agentContent = `---
|
|
24
|
+
name: test-agent
|
|
25
|
+
description: A test agent
|
|
26
|
+
model: gpt-4
|
|
27
|
+
tools:
|
|
28
|
+
- name: test-tool
|
|
29
|
+
description: A test tool
|
|
30
|
+
execution:
|
|
31
|
+
type: shell
|
|
32
|
+
run: echo "hello"
|
|
33
|
+
---
|
|
34
|
+
You are a test agent.
|
|
35
|
+
`;
|
|
36
|
+
const filePath = join(tempDir, 'test-agent.md');
|
|
37
|
+
writeFileSync(filePath, agentContent);
|
|
38
|
+
|
|
39
|
+
const agent = parseAgent(filePath);
|
|
40
|
+
expect(agent.name).toBe('test-agent');
|
|
41
|
+
expect(agent.description).toBe('A test agent');
|
|
42
|
+
expect(agent.model).toBe('gpt-4');
|
|
43
|
+
expect(agent.tools).toHaveLength(1);
|
|
44
|
+
expect(agent.tools[0].name).toBe('test-tool');
|
|
45
|
+
expect(agent.tools[0].execution.id).toBe('tool-test-tool');
|
|
46
|
+
expect(agent.systemPrompt).toBe('You are a test agent.');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should inject tool IDs if missing', () => {
|
|
50
|
+
const agentContent = `---
|
|
51
|
+
name: test-agent
|
|
52
|
+
tools:
|
|
53
|
+
- name: tool-without-id
|
|
54
|
+
execution:
|
|
55
|
+
type: shell
|
|
56
|
+
run: ls
|
|
57
|
+
---
|
|
58
|
+
`;
|
|
59
|
+
const filePath = join(tempDir, 'test-id-injection.md');
|
|
60
|
+
writeFileSync(filePath, agentContent);
|
|
61
|
+
|
|
62
|
+
const agent = parseAgent(filePath);
|
|
63
|
+
expect(agent.tools[0].execution.id).toBe('tool-tool-without-id');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should throw error for missing frontmatter', () => {
|
|
67
|
+
const agentContent = 'Just some content without frontmatter';
|
|
68
|
+
const filePath = join(tempDir, 'invalid-format.md');
|
|
69
|
+
writeFileSync(filePath, agentContent);
|
|
70
|
+
|
|
71
|
+
expect(() => parseAgent(filePath)).toThrow(/Missing frontmatter/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should throw error for invalid schema', () => {
|
|
75
|
+
const agentContent = `---
|
|
76
|
+
name: 123
|
|
77
|
+
---
|
|
78
|
+
Prompt`;
|
|
79
|
+
const filePath = join(tempDir, 'invalid-schema.md');
|
|
80
|
+
writeFileSync(filePath, agentContent);
|
|
81
|
+
expect(() => parseAgent(filePath)).toThrow(/Invalid agent definition/);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('resolveAgentPath', () => {
|
|
86
|
+
it('should resolve agent path in .keystone/workflows/agents', () => {
|
|
87
|
+
const agentsDir = join(process.cwd(), '.keystone', 'workflows', 'agents');
|
|
88
|
+
try {
|
|
89
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
90
|
+
} catch (e) {}
|
|
91
|
+
|
|
92
|
+
const filePath = join(agentsDir, 'my-agent.md');
|
|
93
|
+
writeFileSync(filePath, '---name: my-agent---');
|
|
94
|
+
|
|
95
|
+
const resolved = resolveAgentPath('my-agent');
|
|
96
|
+
expect(resolved).toBe(filePath);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should look in the home directory .keystone/workflows/agents folder', () => {
|
|
100
|
+
const mockHome = join(tempDir, 'mock-home');
|
|
101
|
+
const keystoneDir = join(mockHome, '.keystone', 'workflows', 'agents');
|
|
102
|
+
mkdirSync(keystoneDir, { recursive: true });
|
|
103
|
+
|
|
104
|
+
const agentPath = join(keystoneDir, 'home-agent.md');
|
|
105
|
+
writeFileSync(agentPath, '---name: home-agent---');
|
|
106
|
+
|
|
107
|
+
const homedirSpy = spyOn(os, 'homedir').mockReturnValue(mockHome);
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const resolved = resolveAgentPath('home-agent');
|
|
111
|
+
expect(resolved).toBe(agentPath);
|
|
112
|
+
} finally {
|
|
113
|
+
homedirSpy.mockRestore();
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should throw error if agent not found', () => {
|
|
118
|
+
expect(() => resolveAgentPath('non-existent-agent')).toThrow(
|
|
119
|
+
/Agent "non-existent-agent" not found/
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import yaml from 'js-yaml';
|
|
5
|
+
import { type Agent, AgentSchema } from './schema';
|
|
6
|
+
|
|
7
|
+
export function parseAgent(filePath: string): Agent {
|
|
8
|
+
const content = readFileSync(filePath, 'utf8');
|
|
9
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n([\s\S]*))?$/);
|
|
10
|
+
|
|
11
|
+
if (!match) {
|
|
12
|
+
throw new Error(`Invalid agent format in ${filePath}. Missing frontmatter.`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const frontmatter = yaml.load(match[1]) as Record<string, unknown>;
|
|
16
|
+
|
|
17
|
+
// Inject IDs into tool executions if missing
|
|
18
|
+
if (frontmatter.tools && Array.isArray(frontmatter.tools)) {
|
|
19
|
+
frontmatter.tools = frontmatter.tools.map((tool: unknown) => {
|
|
20
|
+
const t = tool as Record<string, unknown>;
|
|
21
|
+
if (t.execution && typeof t.execution === 'object') {
|
|
22
|
+
const exec = t.execution as Record<string, unknown>;
|
|
23
|
+
if (!exec.id) {
|
|
24
|
+
exec.id = `tool-${t.name || 'unknown'}`;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return t;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const systemPrompt = match[2]?.trim() || '';
|
|
32
|
+
|
|
33
|
+
const agentData = {
|
|
34
|
+
...frontmatter,
|
|
35
|
+
systemPrompt,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const result = AgentSchema.safeParse(agentData);
|
|
39
|
+
if (!result.success) {
|
|
40
|
+
throw new Error(`Invalid agent definition in ${filePath}: ${result.error.message}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return result.data;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function resolveAgentPath(agentName: string): string {
|
|
47
|
+
const possiblePaths = [
|
|
48
|
+
join(process.cwd(), '.keystone', 'workflows', 'agents', `${agentName}.md`),
|
|
49
|
+
join(homedir(), '.keystone', 'workflows', 'agents', `${agentName}.md`),
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
for (const path of possiblePaths) {
|
|
53
|
+
if (existsSync(path)) {
|
|
54
|
+
return path;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
throw new Error(`Agent "${agentName}" not found. Expected one of:\n${possiblePaths.join('\n')}`);
|
|
59
|
+
}
|