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,354 @@
1
+ import type { ExpressionContext } from '../expression/evaluator.ts';
2
+ import { ExpressionEvaluator } from '../expression/evaluator.ts';
3
+ // Removed synchronous file I/O imports - using Bun's async file API instead
4
+ import type {
5
+ FileStep,
6
+ HumanStep,
7
+ RequestStep,
8
+ ShellStep,
9
+ SleepStep,
10
+ Step,
11
+ WorkflowStep,
12
+ } from '../parser/schema.ts';
13
+ import { executeShell } from './shell-executor.ts';
14
+ import type { Logger } from './workflow-runner.ts';
15
+
16
+ import * as readline from 'node:readline/promises';
17
+ import { executeLlmStep } from './llm-executor.ts';
18
+ import type { MCPManager } from './mcp-manager.ts';
19
+
20
+ export class WorkflowSuspendedError extends Error {
21
+ constructor(
22
+ public readonly message: string,
23
+ public readonly stepId: string,
24
+ public readonly inputType: 'confirm' | 'text'
25
+ ) {
26
+ super(message);
27
+ this.name = 'WorkflowSuspendedError';
28
+ }
29
+ }
30
+
31
+ export interface StepResult {
32
+ output: unknown;
33
+ status: 'success' | 'failed' | 'suspended';
34
+ error?: string;
35
+ }
36
+
37
+ /**
38
+ * Execute a single step based on its type
39
+ */
40
+ export async function executeStep(
41
+ step: Step,
42
+ context: ExpressionContext,
43
+ logger: Logger = console,
44
+ executeWorkflowFn?: (step: WorkflowStep, context: ExpressionContext) => Promise<StepResult>,
45
+ mcpManager?: MCPManager
46
+ ): Promise<StepResult> {
47
+ try {
48
+ let result: StepResult;
49
+ switch (step.type) {
50
+ case 'shell':
51
+ result = await executeShellStep(step, context, logger);
52
+ break;
53
+ case 'file':
54
+ result = await executeFileStep(step, context, logger);
55
+ break;
56
+ case 'request':
57
+ result = await executeRequestStep(step, context, logger);
58
+ break;
59
+ case 'human':
60
+ result = await executeHumanStep(step, context, logger);
61
+ break;
62
+ case 'sleep':
63
+ result = await executeSleepStep(step, context, logger);
64
+ break;
65
+ case 'llm':
66
+ result = await executeLlmStep(
67
+ step,
68
+ context,
69
+ (s, c) => executeStep(s, c, logger, executeWorkflowFn, mcpManager),
70
+ logger,
71
+ mcpManager
72
+ );
73
+ break;
74
+ case 'workflow':
75
+ if (!executeWorkflowFn) {
76
+ throw new Error('Workflow executor not provided');
77
+ }
78
+ result = await executeWorkflowFn(step, context);
79
+ break;
80
+ default:
81
+ throw new Error(`Unknown step type: ${(step as Step).type}`);
82
+ }
83
+
84
+ // Apply transformation if specified and step succeeded
85
+ if (step.transform && result.status === 'success') {
86
+ const transformContext = {
87
+ // Provide raw output properties (like stdout, data) directly in context
88
+ // Fix: Spread output FIRST, then context to prevent shadowing
89
+ ...(typeof result.output === 'object' && result.output !== null ? result.output : {}),
90
+ output: result.output,
91
+ ...context,
92
+ };
93
+
94
+ try {
95
+ // If it's wrapped in ${{ }}, extract it, otherwise treat as raw expression
96
+ let expr = step.transform.trim();
97
+ if (expr.startsWith('${{') && expr.endsWith('}}')) {
98
+ expr = expr.slice(3, -2).trim();
99
+ }
100
+ result.output = ExpressionEvaluator.evaluateExpression(expr, transformContext);
101
+ } catch (error) {
102
+ throw new Error(
103
+ `Transform failed for step ${step.id}: ${error instanceof Error ? error.message : String(error)}`
104
+ );
105
+ }
106
+ }
107
+
108
+ return result;
109
+ } catch (error) {
110
+ return {
111
+ output: null,
112
+ status: 'failed',
113
+ error: error instanceof Error ? error.message : String(error),
114
+ };
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Execute a shell step
120
+ */
121
+ async function executeShellStep(
122
+ step: ShellStep,
123
+ context: ExpressionContext,
124
+ logger: Logger
125
+ ): Promise<StepResult> {
126
+ const result = await executeShell(step, context, logger);
127
+
128
+ if (result.stdout) {
129
+ logger.log(result.stdout.trim());
130
+ }
131
+
132
+ if (result.exitCode !== 0) {
133
+ return {
134
+ output: {
135
+ stdout: result.stdout,
136
+ stderr: result.stderr,
137
+ exitCode: result.exitCode,
138
+ },
139
+ status: 'failed',
140
+ error: `Shell command exited with code ${result.exitCode}: ${result.stderr}`,
141
+ };
142
+ }
143
+
144
+ return {
145
+ output: {
146
+ stdout: result.stdout,
147
+ stderr: result.stderr,
148
+ exitCode: result.exitCode,
149
+ },
150
+ status: 'success',
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Execute a file step (read, write, append)
156
+ */
157
+ async function executeFileStep(
158
+ step: FileStep,
159
+ context: ExpressionContext,
160
+ _logger: Logger
161
+ ): Promise<StepResult> {
162
+ const path = ExpressionEvaluator.evaluate(step.path, context) as string;
163
+
164
+ switch (step.op) {
165
+ case 'read': {
166
+ const file = Bun.file(path);
167
+ if (!(await file.exists())) {
168
+ throw new Error(`File not found: ${path}`);
169
+ }
170
+ const content = await file.text();
171
+ return {
172
+ output: content,
173
+ status: 'success',
174
+ };
175
+ }
176
+
177
+ case 'write': {
178
+ if (!step.content) {
179
+ throw new Error('Content is required for write operation');
180
+ }
181
+ const content = ExpressionEvaluator.evaluate(step.content, context) as string;
182
+ const bytes = await Bun.write(path, content);
183
+ return {
184
+ output: { path, bytes },
185
+ status: 'success',
186
+ };
187
+ }
188
+
189
+ case 'append': {
190
+ if (!step.content) {
191
+ throw new Error('Content is required for append operation');
192
+ }
193
+ const content = ExpressionEvaluator.evaluate(step.content, context) as string;
194
+
195
+ // Use Node.js fs for efficient append operation
196
+ const fs = await import('node:fs/promises');
197
+ await fs.appendFile(path, content, 'utf-8');
198
+
199
+ return {
200
+ output: { path, bytes: content.length },
201
+ status: 'success',
202
+ };
203
+ }
204
+
205
+ default:
206
+ throw new Error(`Unknown file operation: ${step.op}`);
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Execute an HTTP request step
212
+ */
213
+ async function executeRequestStep(
214
+ step: RequestStep,
215
+ context: ExpressionContext,
216
+ _logger: Logger
217
+ ): Promise<StepResult> {
218
+ const url = ExpressionEvaluator.evaluate(step.url, context) as string;
219
+
220
+ // Evaluate headers
221
+ const headers: Record<string, string> = {};
222
+ if (step.headers) {
223
+ for (const [key, value] of Object.entries(step.headers)) {
224
+ headers[key] = ExpressionEvaluator.evaluate(value, context) as string;
225
+ }
226
+ }
227
+
228
+ // Evaluate body
229
+ let body: string | undefined;
230
+ if (step.body) {
231
+ const evaluatedBody = ExpressionEvaluator.evaluateObject(step.body, context);
232
+
233
+ const contentType = Object.entries(headers).find(
234
+ ([k]) => k.toLowerCase() === 'content-type'
235
+ )?.[1];
236
+
237
+ if (contentType?.includes('application/x-www-form-urlencoded')) {
238
+ if (typeof evaluatedBody === 'object' && evaluatedBody !== null) {
239
+ const params = new URLSearchParams();
240
+ for (const [key, value] of Object.entries(evaluatedBody)) {
241
+ params.append(key, String(value));
242
+ }
243
+ body = params.toString();
244
+ } else {
245
+ body = String(evaluatedBody);
246
+ }
247
+ } else {
248
+ // Default to JSON if not form-encoded and not already a string
249
+ body = typeof evaluatedBody === 'string' ? evaluatedBody : JSON.stringify(evaluatedBody);
250
+
251
+ // Auto-set Content-Type to application/json if not already set and body is an object
252
+ if (!contentType && typeof evaluatedBody === 'object' && evaluatedBody !== null) {
253
+ headers['Content-Type'] = 'application/json';
254
+ }
255
+ }
256
+ }
257
+
258
+ const response = await fetch(url, {
259
+ method: step.method,
260
+ headers,
261
+ body,
262
+ });
263
+
264
+ const responseText = await response.text();
265
+ let responseData: unknown;
266
+
267
+ try {
268
+ responseData = JSON.parse(responseText);
269
+ } catch {
270
+ responseData = responseText;
271
+ }
272
+
273
+ return {
274
+ output: {
275
+ status: response.status,
276
+ statusText: response.statusText,
277
+ headers: Object.fromEntries(response.headers.entries()),
278
+ data: responseData,
279
+ },
280
+ status: response.ok ? 'success' : 'failed',
281
+ error: response.ok ? undefined : `HTTP ${response.status}: ${response.statusText}`,
282
+ };
283
+ }
284
+
285
+ /**
286
+ * Execute a human input step
287
+ */
288
+ async function executeHumanStep(
289
+ step: HumanStep,
290
+ context: ExpressionContext,
291
+ logger: Logger
292
+ ): Promise<StepResult> {
293
+ const message = ExpressionEvaluator.evaluate(step.message, context) as string;
294
+
295
+ // If not a TTY (e.g. MCP server), suspend execution
296
+ if (!process.stdin.isTTY) {
297
+ return {
298
+ output: null,
299
+ status: 'suspended',
300
+ error: message,
301
+ };
302
+ }
303
+
304
+ const rl = readline.createInterface({
305
+ input: process.stdin,
306
+ output: process.stdout,
307
+ });
308
+
309
+ try {
310
+ if (step.inputType === 'confirm') {
311
+ logger.log(`\n❓ ${message}`);
312
+ logger.log('Press Enter to continue, or Ctrl+C to cancel...');
313
+ await rl.question('');
314
+ return {
315
+ output: true,
316
+ status: 'success',
317
+ };
318
+ }
319
+
320
+ // Text input
321
+ logger.log(`\n❓ ${message}`);
322
+ logger.log('Enter your response:');
323
+ const input = await rl.question('');
324
+ return {
325
+ output: input.trim(),
326
+ status: 'success',
327
+ };
328
+ } finally {
329
+ rl.close();
330
+ }
331
+ }
332
+
333
+ /**
334
+ * Execute a sleep step
335
+ */
336
+ async function executeSleepStep(
337
+ step: SleepStep,
338
+ context: ExpressionContext,
339
+ _logger: Logger
340
+ ): Promise<StepResult> {
341
+ const evaluated = ExpressionEvaluator.evaluate(step.duration.toString(), context);
342
+ const duration = Number(evaluated);
343
+
344
+ if (Number.isNaN(duration)) {
345
+ throw new Error(`Invalid sleep duration: ${evaluated}`);
346
+ }
347
+
348
+ await new Promise((resolve) => setTimeout(resolve, duration));
349
+
350
+ return {
351
+ output: { slept: duration },
352
+ status: 'success',
353
+ };
354
+ }
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { TimeoutError, withTimeout } from './timeout';
3
+
4
+ describe('timeout', () => {
5
+ it('should resolve if the promise completes before the timeout', async () => {
6
+ const promise = Promise.resolve('ok');
7
+ const result = await withTimeout(promise, 100);
8
+ expect(result).toBe('ok');
9
+ });
10
+
11
+ it('should reject if the promise takes longer than the timeout', async () => {
12
+ const promise = new Promise((resolve) => setTimeout(() => resolve('ok'), 200));
13
+ await expect(withTimeout(promise, 50)).rejects.toThrow(TimeoutError);
14
+ });
15
+
16
+ it('should include the operation name in the error message', async () => {
17
+ const promise = new Promise((resolve) => setTimeout(() => resolve('ok'), 100));
18
+ await expect(withTimeout(promise, 10, 'MyStep')).rejects.toThrow(/MyStep timed out/);
19
+ });
20
+ });
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Execute a promise with a timeout
3
+ * Throws a TimeoutError if the operation exceeds the timeout
4
+ */
5
+ export class TimeoutError extends Error {
6
+ constructor(message: string) {
7
+ super(message);
8
+ this.name = 'TimeoutError';
9
+ }
10
+ }
11
+
12
+ export async function withTimeout<T>(
13
+ promise: Promise<T>,
14
+ timeoutMs: number,
15
+ operation = 'Operation'
16
+ ): Promise<T> {
17
+ let timeoutId: Timer;
18
+
19
+ const timeoutPromise = new Promise<never>((_, reject) => {
20
+ timeoutId = setTimeout(() => {
21
+ reject(new TimeoutError(`${operation} timed out after ${timeoutMs}ms`));
22
+ }, timeoutMs);
23
+ });
24
+
25
+ try {
26
+ return await Promise.race([promise, timeoutPromise]);
27
+ } finally {
28
+ clearTimeout(timeoutId);
29
+ }
30
+ }
@@ -0,0 +1,198 @@
1
+ import { afterAll, beforeAll, describe, expect, it, mock } from 'bun:test';
2
+ import { OpenAIAdapter, CopilotAdapter, AnthropicAdapter } from './llm-adapter';
3
+ import { MCPClient } from './mcp-client';
4
+ import { executeLlmStep } from './llm-executor';
5
+ import type { LlmStep, Step } from '../parser/schema';
6
+ import type { ExpressionContext } from '../expression/evaluator';
7
+ import type { StepResult } from './step-executor';
8
+ import { join } from 'node:path';
9
+ import { mkdirSync, writeFileSync, unlinkSync } from 'node:fs';
10
+
11
+ interface MockToolCall {
12
+ function: {
13
+ name: string;
14
+ };
15
+ }
16
+
17
+ describe('llm-executor with tools and MCP', () => {
18
+ const agentsDir = join(process.cwd(), '.keystone', 'workflows', 'agents');
19
+ const agentPath = join(agentsDir, 'tool-test-agent.md');
20
+
21
+ beforeAll(() => {
22
+ try {
23
+ mkdirSync(agentsDir, { recursive: true });
24
+ } catch (e) {}
25
+ const agentContent = `---
26
+ name: tool-test-agent
27
+ tools:
28
+ - name: agent-tool
29
+ execution:
30
+ id: agent-tool-exec
31
+ type: shell
32
+ run: echo "agent tool"
33
+ ---
34
+ Test system prompt`;
35
+ writeFileSync(agentPath, agentContent);
36
+ });
37
+
38
+ afterAll(() => {
39
+ try {
40
+ unlinkSync(agentPath);
41
+ } catch (e) {}
42
+ });
43
+
44
+ it('should merge tools from agent, step and MCP', async () => {
45
+ const originalOpenAIChat = OpenAIAdapter.prototype.chat;
46
+ const originalCopilotChat = CopilotAdapter.prototype.chat;
47
+ const originalAnthropicChat = AnthropicAdapter.prototype.chat;
48
+ let capturedTools: MockToolCall[] = [];
49
+
50
+ const mockChat = mock(async (_messages: unknown, options: unknown) => {
51
+ capturedTools = (options as { tools?: MockToolCall[] })?.tools || [];
52
+ return {
53
+ message: { role: 'assistant', content: 'Final response' },
54
+ };
55
+ });
56
+
57
+ OpenAIAdapter.prototype.chat = mockChat as any;
58
+ CopilotAdapter.prototype.chat = mockChat as any;
59
+ AnthropicAdapter.prototype.chat = mockChat as any;
60
+
61
+ // Use mock.module for MCPClient
62
+ const originalInitialize = MCPClient.prototype.initialize;
63
+ const originalListTools = MCPClient.prototype.listTools;
64
+ const originalStop = MCPClient.prototype.stop;
65
+
66
+ const mockInitialize = mock(async () => ({}) as any);
67
+ const mockListTools = mock(async () => [
68
+ {
69
+ name: 'mcp-tool',
70
+ description: 'MCP tool',
71
+ inputSchema: { type: 'object', properties: {} },
72
+ },
73
+ ]);
74
+ const mockStop = mock(() => {});
75
+
76
+ MCPClient.prototype.initialize = mockInitialize;
77
+ MCPClient.prototype.listTools = mockListTools;
78
+ MCPClient.prototype.stop = mockStop;
79
+
80
+ const step: LlmStep = {
81
+ id: 'l1',
82
+ type: 'llm',
83
+ agent: 'tool-test-agent',
84
+ prompt: 'test',
85
+ needs: [],
86
+ tools: [
87
+ {
88
+ name: 'step-tool',
89
+ execution: { id: 'step-tool-exec', type: 'shell', run: 'echo step' },
90
+ },
91
+ ],
92
+ mcpServers: [{ name: 'test-mcp', command: 'node', args: ['-e', ''] }],
93
+ };
94
+
95
+ const context: ExpressionContext = { inputs: {}, steps: {} };
96
+ const executeStepFn = async () => ({ status: 'success' as const, output: {} });
97
+
98
+ await executeLlmStep(
99
+ step,
100
+ context,
101
+ executeStepFn as unknown as (step: Step, context: ExpressionContext) => Promise<StepResult>
102
+ );
103
+
104
+ const toolNames = capturedTools.map((t) => t.function.name);
105
+ expect(toolNames).toContain('agent-tool');
106
+ expect(toolNames).toContain('step-tool');
107
+ expect(toolNames).toContain('mcp-tool');
108
+
109
+ OpenAIAdapter.prototype.chat = originalOpenAIChat;
110
+ CopilotAdapter.prototype.chat = originalCopilotChat;
111
+ AnthropicAdapter.prototype.chat = originalAnthropicChat;
112
+ MCPClient.prototype.initialize = originalInitialize;
113
+ MCPClient.prototype.listTools = originalListTools;
114
+ MCPClient.prototype.stop = originalStop;
115
+ });
116
+
117
+ it('should execute MCP tool when called', async () => {
118
+ const originalOpenAIChat = OpenAIAdapter.prototype.chat;
119
+ const originalCopilotChat = CopilotAdapter.prototype.chat;
120
+ const originalAnthropicChat = AnthropicAdapter.prototype.chat;
121
+ let chatCount = 0;
122
+
123
+ const mockChat = mock(async () => {
124
+ chatCount++;
125
+ if (chatCount === 1) {
126
+ return {
127
+ message: {
128
+ role: 'assistant',
129
+ tool_calls: [
130
+ {
131
+ id: 'call-1',
132
+ type: 'function',
133
+ function: { name: 'mcp-tool', arguments: '{}' },
134
+ },
135
+ ],
136
+ },
137
+ };
138
+ }
139
+ return {
140
+ message: { role: 'assistant', content: 'Done' },
141
+ };
142
+ });
143
+
144
+ OpenAIAdapter.prototype.chat = mockChat as any;
145
+ CopilotAdapter.prototype.chat = mockChat as any;
146
+ AnthropicAdapter.prototype.chat = mockChat as any;
147
+
148
+ const originalInitialize = MCPClient.prototype.initialize;
149
+ const originalListTools = MCPClient.prototype.listTools;
150
+ const originalCallTool = MCPClient.prototype.callTool;
151
+ const originalStop = MCPClient.prototype.stop;
152
+
153
+ const mockInitialize = mock(async () => ({}) as any);
154
+ const mockListTools = mock(async () => [
155
+ {
156
+ name: 'mcp-tool',
157
+ description: 'MCP tool',
158
+ inputSchema: { type: 'object', properties: {} },
159
+ },
160
+ ]);
161
+ const mockCallTool = mock(async () => ({ result: 'mcp success' }));
162
+ const mockStop = mock(() => {});
163
+
164
+ MCPClient.prototype.initialize = mockInitialize;
165
+ MCPClient.prototype.listTools = mockListTools;
166
+ MCPClient.prototype.callTool = mockCallTool;
167
+ MCPClient.prototype.stop = mockStop;
168
+
169
+ const step: LlmStep = {
170
+ id: 'l1',
171
+ type: 'llm',
172
+ agent: 'tool-test-agent',
173
+ prompt: 'test',
174
+ needs: [],
175
+ mcpServers: [{ name: 'test-mcp', command: 'node', args: ['-e', ''] }],
176
+ };
177
+
178
+ const context: ExpressionContext = { inputs: {}, steps: {} };
179
+ const executeStepFn = async () => ({ status: 'success' as const, output: {} });
180
+
181
+ await executeLlmStep(
182
+ step,
183
+ context,
184
+ executeStepFn as unknown as (step: Step, context: ExpressionContext) => Promise<StepResult>
185
+ );
186
+
187
+ expect(mockCallTool).toHaveBeenCalledWith('mcp-tool', {});
188
+ expect(chatCount).toBe(2);
189
+
190
+ OpenAIAdapter.prototype.chat = originalOpenAIChat;
191
+ CopilotAdapter.prototype.chat = originalCopilotChat;
192
+ AnthropicAdapter.prototype.chat = originalAnthropicChat;
193
+ MCPClient.prototype.initialize = originalInitialize;
194
+ MCPClient.prototype.listTools = originalListTools;
195
+ MCPClient.prototype.callTool = originalCallTool;
196
+ MCPClient.prototype.stop = originalStop;
197
+ });
198
+ });