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.
Files changed (46) hide show
  1. package/README.md +136 -0
  2. package/logo.png +0 -0
  3. package/package.json +45 -0
  4. package/src/cli.ts +775 -0
  5. package/src/db/workflow-db.test.ts +99 -0
  6. package/src/db/workflow-db.ts +265 -0
  7. package/src/expression/evaluator.test.ts +247 -0
  8. package/src/expression/evaluator.ts +517 -0
  9. package/src/parser/agent-parser.test.ts +123 -0
  10. package/src/parser/agent-parser.ts +59 -0
  11. package/src/parser/config-schema.ts +54 -0
  12. package/src/parser/schema.ts +157 -0
  13. package/src/parser/workflow-parser.test.ts +212 -0
  14. package/src/parser/workflow-parser.ts +228 -0
  15. package/src/runner/llm-adapter.test.ts +329 -0
  16. package/src/runner/llm-adapter.ts +306 -0
  17. package/src/runner/llm-executor.test.ts +537 -0
  18. package/src/runner/llm-executor.ts +256 -0
  19. package/src/runner/mcp-client.test.ts +122 -0
  20. package/src/runner/mcp-client.ts +123 -0
  21. package/src/runner/mcp-manager.test.ts +143 -0
  22. package/src/runner/mcp-manager.ts +85 -0
  23. package/src/runner/mcp-server.test.ts +242 -0
  24. package/src/runner/mcp-server.ts +436 -0
  25. package/src/runner/retry.test.ts +52 -0
  26. package/src/runner/retry.ts +58 -0
  27. package/src/runner/shell-executor.test.ts +123 -0
  28. package/src/runner/shell-executor.ts +166 -0
  29. package/src/runner/step-executor.test.ts +465 -0
  30. package/src/runner/step-executor.ts +354 -0
  31. package/src/runner/timeout.test.ts +20 -0
  32. package/src/runner/timeout.ts +30 -0
  33. package/src/runner/tool-integration.test.ts +198 -0
  34. package/src/runner/workflow-runner.test.ts +358 -0
  35. package/src/runner/workflow-runner.ts +955 -0
  36. package/src/ui/dashboard.tsx +165 -0
  37. package/src/utils/auth-manager.test.ts +152 -0
  38. package/src/utils/auth-manager.ts +88 -0
  39. package/src/utils/config-loader.test.ts +52 -0
  40. package/src/utils/config-loader.ts +85 -0
  41. package/src/utils/mermaid.test.ts +51 -0
  42. package/src/utils/mermaid.ts +87 -0
  43. package/src/utils/redactor.test.ts +66 -0
  44. package/src/utils/redactor.ts +60 -0
  45. package/src/utils/workflow-registry.test.ts +108 -0
  46. 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
+ }