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,436 @@
1
+ import * as readline from 'node:readline';
2
+ import { WorkflowDb } from '../db/workflow-db';
3
+ import { WorkflowParser } from '../parser/workflow-parser';
4
+ import { generateMermaidGraph } from '../utils/mermaid';
5
+ import { WorkflowRegistry } from '../utils/workflow-registry';
6
+ import { WorkflowSuspendedError } from './step-executor';
7
+ import { WorkflowRunner } from './workflow-runner';
8
+
9
+ interface MCPMessage {
10
+ jsonrpc: '2.0';
11
+ method: string;
12
+ params?: unknown;
13
+ id?: string | number;
14
+ }
15
+
16
+ export class MCPServer {
17
+ private db: WorkflowDb;
18
+
19
+ constructor(db?: WorkflowDb) {
20
+ this.db = db || new WorkflowDb();
21
+ }
22
+
23
+ async start() {
24
+ const rl = readline.createInterface({
25
+ input: process.stdin,
26
+ terminal: false,
27
+ });
28
+
29
+ rl.on('line', async (line) => {
30
+ if (!line.trim()) return;
31
+
32
+ try {
33
+ const message = JSON.parse(line) as MCPMessage;
34
+ const response = await this.handleMessage(message);
35
+ if (response) {
36
+ process.stdout.write(`${JSON.stringify(response)}\n`);
37
+ }
38
+ } catch (error) {
39
+ console.error('Error handling MCP message:', error);
40
+ }
41
+ });
42
+ }
43
+
44
+ private async handleMessage(message: MCPMessage) {
45
+ const { method, params, id } = message;
46
+
47
+ switch (method) {
48
+ case 'initialize':
49
+ return {
50
+ jsonrpc: '2.0',
51
+ id,
52
+ result: {
53
+ protocolVersion: '2024-11-05',
54
+ capabilities: {
55
+ tools: {},
56
+ },
57
+ serverInfo: {
58
+ name: 'keystone-mcp',
59
+ version: '0.1.0',
60
+ },
61
+ },
62
+ };
63
+
64
+ case 'tools/list':
65
+ return {
66
+ jsonrpc: '2.0',
67
+ id,
68
+ result: {
69
+ tools: [
70
+ {
71
+ name: 'list_workflows',
72
+ description: 'List all available workflows and their required inputs.',
73
+ inputSchema: {
74
+ type: 'object',
75
+ properties: {},
76
+ },
77
+ },
78
+ {
79
+ name: 'run_workflow',
80
+ description: 'Execute a workflow by name.',
81
+ inputSchema: {
82
+ type: 'object',
83
+ properties: {
84
+ workflow_name: {
85
+ type: 'string',
86
+ description: 'The name of the workflow to run (e.g., "deploy", "cleanup")',
87
+ },
88
+ inputs: {
89
+ type: 'object',
90
+ description: 'Key-value pairs for workflow inputs',
91
+ },
92
+ },
93
+ required: ['workflow_name'],
94
+ },
95
+ },
96
+ {
97
+ name: 'get_run_logs',
98
+ description: 'Get the logs and status of a specific workflow run.',
99
+ inputSchema: {
100
+ type: 'object',
101
+ properties: {
102
+ run_id: { type: 'string' },
103
+ },
104
+ required: ['run_id'],
105
+ },
106
+ },
107
+ {
108
+ name: 'get_workflow_graph',
109
+ description: 'Get a visual diagram (Mermaid.js) of the workflow structure.',
110
+ inputSchema: {
111
+ type: 'object',
112
+ properties: {
113
+ workflow_name: { type: 'string' },
114
+ },
115
+ required: ['workflow_name'],
116
+ },
117
+ },
118
+ {
119
+ name: 'answer_human_input',
120
+ description:
121
+ 'Provide input to a workflow that is paused waiting for human interaction.',
122
+ inputSchema: {
123
+ type: 'object',
124
+ properties: {
125
+ run_id: { type: 'string', description: 'The ID of the paused run' },
126
+ input: {
127
+ type: 'string',
128
+ description: 'The text input or "confirm" for confirmation steps',
129
+ },
130
+ },
131
+ required: ['run_id', 'input'],
132
+ },
133
+ },
134
+ ],
135
+ },
136
+ };
137
+
138
+ case 'tools/call': {
139
+ const toolParams = params as { name: string; arguments: Record<string, unknown> };
140
+
141
+ try {
142
+ // --- Tool: list_workflows ---
143
+ if (toolParams.name === 'list_workflows') {
144
+ const workflows = WorkflowRegistry.listWorkflows();
145
+ return {
146
+ jsonrpc: '2.0',
147
+ id,
148
+ result: {
149
+ content: [{ type: 'text', text: JSON.stringify(workflows, null, 2) }],
150
+ },
151
+ };
152
+ }
153
+
154
+ // --- Tool: run_workflow ---
155
+ if (toolParams.name === 'run_workflow') {
156
+ const { workflow_name, inputs } = toolParams.arguments as {
157
+ workflow_name: string;
158
+ inputs: Record<string, unknown>;
159
+ };
160
+
161
+ const path = WorkflowRegistry.resolvePath(workflow_name);
162
+ const workflow = WorkflowParser.loadWorkflow(path);
163
+
164
+ // Use a custom logger that captures logs for the MCP response
165
+ const logs: string[] = [];
166
+ const logger = {
167
+ log: (msg: string) => logs.push(msg),
168
+ error: (msg: string) => logs.push(`ERROR: ${msg}`),
169
+ warn: (msg: string) => logs.push(`WARN: ${msg}`),
170
+ };
171
+
172
+ const runner = new WorkflowRunner(workflow, {
173
+ inputs,
174
+ logger,
175
+ });
176
+
177
+ // Note: This waits for completion. For long workflows, we might want to
178
+ // return the run_id immediately and let the agent poll via get_run_logs.
179
+ // For now, synchronous is easier for the agent to reason about.
180
+ let outputs: Record<string, unknown> | undefined;
181
+ try {
182
+ outputs = await runner.run();
183
+ } catch (error) {
184
+ if (error instanceof WorkflowSuspendedError) {
185
+ return {
186
+ jsonrpc: '2.0',
187
+ id,
188
+ result: {
189
+ content: [
190
+ {
191
+ type: 'text',
192
+ text: JSON.stringify(
193
+ {
194
+ status: 'paused',
195
+ run_id: runner.getRunId(),
196
+ message: error.message,
197
+ step_id: error.stepId,
198
+ input_type: error.inputType,
199
+ instructions:
200
+ error.inputType === 'confirm'
201
+ ? 'Use answer_human_input with input="confirm" to proceed.'
202
+ : 'Use answer_human_input with the required text input.',
203
+ },
204
+ null,
205
+ 2
206
+ ),
207
+ },
208
+ ],
209
+ },
210
+ };
211
+ }
212
+ // Even if it fails, we return the logs so the agent knows why
213
+ return {
214
+ jsonrpc: '2.0',
215
+ id,
216
+ result: {
217
+ isError: true,
218
+ content: [
219
+ {
220
+ type: 'text',
221
+ text: `Workflow failed.\n\nLogs:\n${logs.join('\n')}`,
222
+ },
223
+ ],
224
+ },
225
+ };
226
+ }
227
+
228
+ return {
229
+ jsonrpc: '2.0',
230
+ id,
231
+ result: {
232
+ content: [
233
+ {
234
+ type: 'text',
235
+ text: JSON.stringify(
236
+ {
237
+ status: 'success',
238
+ outputs,
239
+ logs: logs.slice(-20), // Return last 20 lines to avoid token limits
240
+ },
241
+ null,
242
+ 2
243
+ ),
244
+ },
245
+ ],
246
+ },
247
+ };
248
+ }
249
+
250
+ // --- Tool: get_run_logs ---
251
+ if (toolParams.name === 'get_run_logs') {
252
+ const { run_id } = toolParams.arguments as { run_id: string };
253
+ const run = this.db.getRun(run_id);
254
+
255
+ if (!run) {
256
+ throw new Error(`Run ID ${run_id} not found`);
257
+ }
258
+
259
+ const steps = this.db.getStepsByRun(run_id);
260
+ const summary = {
261
+ workflow: run.workflow_name,
262
+ status: run.status,
263
+ error: run.error,
264
+ steps: steps.map((s) => ({
265
+ step: s.step_id,
266
+ status: s.status,
267
+ error: s.error,
268
+ output: s.output ? JSON.parse(s.output) : null,
269
+ })),
270
+ };
271
+
272
+ return {
273
+ jsonrpc: '2.0',
274
+ id,
275
+ result: {
276
+ content: [{ type: 'text', text: JSON.stringify(summary, null, 2) }],
277
+ },
278
+ };
279
+ }
280
+
281
+ // --- Tool: get_workflow_graph ---
282
+ if (toolParams.name === 'get_workflow_graph') {
283
+ const { workflow_name } = toolParams.arguments as { workflow_name: string };
284
+ const path = WorkflowRegistry.resolvePath(workflow_name);
285
+ const workflow = WorkflowParser.loadWorkflow(path);
286
+
287
+ const mermaid = generateMermaidGraph(workflow);
288
+
289
+ return {
290
+ jsonrpc: '2.0',
291
+ id,
292
+ result: {
293
+ content: [
294
+ {
295
+ type: 'text',
296
+ text: `Here is the graph for **${workflow_name}**:\n\n\`\`\`mermaid\n${mermaid}\n\`\`\``,
297
+ },
298
+ ],
299
+ },
300
+ };
301
+ }
302
+
303
+ // --- Tool: answer_human_input ---
304
+ if (toolParams.name === 'answer_human_input') {
305
+ const { run_id, input } = toolParams.arguments as { run_id: string; input: string };
306
+ const run = this.db.getRun(run_id);
307
+ if (!run) {
308
+ throw new Error(`Run ID ${run_id} not found`);
309
+ }
310
+
311
+ if (run.status !== 'paused') {
312
+ throw new Error(`Run ${run_id} is not paused (status: ${run.status})`);
313
+ }
314
+
315
+ // Find the pending human step
316
+ const steps = this.db.getStepsByRun(run_id);
317
+ const pendingStep = steps.find((s) => s.status === 'pending');
318
+ if (!pendingStep) {
319
+ throw new Error(`No pending step found for run ${run_id}`);
320
+ }
321
+
322
+ // Fulfill the step in the DB
323
+ const output = input === 'confirm' ? true : input;
324
+ await this.db.completeStep(pendingStep.id, 'success', output);
325
+
326
+ // Resume the workflow
327
+ const path = WorkflowRegistry.resolvePath(run.workflow_name);
328
+ const workflow = WorkflowParser.loadWorkflow(path);
329
+
330
+ const logs: string[] = [];
331
+ const logger = {
332
+ log: (msg: string) => logs.push(msg),
333
+ error: (msg: string) => logs.push(`ERROR: ${msg}`),
334
+ warn: (msg: string) => logs.push(`WARN: ${msg}`),
335
+ };
336
+
337
+ const runner = new WorkflowRunner(workflow, {
338
+ resumeRunId: run_id,
339
+ logger,
340
+ });
341
+
342
+ let outputs: Record<string, unknown> | undefined;
343
+ try {
344
+ outputs = await runner.run();
345
+ } catch (error) {
346
+ if (error instanceof WorkflowSuspendedError) {
347
+ return {
348
+ jsonrpc: '2.0',
349
+ id,
350
+ result: {
351
+ content: [
352
+ {
353
+ type: 'text',
354
+ text: JSON.stringify(
355
+ {
356
+ status: 'paused',
357
+ run_id: runner.getRunId(),
358
+ message: error.message,
359
+ step_id: error.stepId,
360
+ input_type: error.inputType,
361
+ instructions:
362
+ error.inputType === 'confirm'
363
+ ? 'Use answer_human_input with input="confirm" to proceed.'
364
+ : 'Use answer_human_input with the required text input.',
365
+ },
366
+ null,
367
+ 2
368
+ ),
369
+ },
370
+ ],
371
+ },
372
+ };
373
+ }
374
+
375
+ return {
376
+ jsonrpc: '2.0',
377
+ id,
378
+ result: {
379
+ isError: true,
380
+ content: [
381
+ {
382
+ type: 'text',
383
+ text: `Workflow failed after resume.\n\nLogs:\n${logs.join('\n')}`,
384
+ },
385
+ ],
386
+ },
387
+ };
388
+ }
389
+
390
+ return {
391
+ jsonrpc: '2.0',
392
+ id,
393
+ result: {
394
+ content: [
395
+ {
396
+ type: 'text',
397
+ text: JSON.stringify(
398
+ {
399
+ status: 'success',
400
+ outputs,
401
+ logs: logs.slice(-20),
402
+ },
403
+ null,
404
+ 2
405
+ ),
406
+ },
407
+ ],
408
+ },
409
+ };
410
+ }
411
+
412
+ throw new Error(`Unknown tool: ${toolParams.name}`);
413
+ } catch (error) {
414
+ return {
415
+ jsonrpc: '2.0',
416
+ id,
417
+ error: {
418
+ code: -32000,
419
+ message: error instanceof Error ? error.message : String(error),
420
+ },
421
+ };
422
+ }
423
+ }
424
+
425
+ default:
426
+ return {
427
+ jsonrpc: '2.0',
428
+ id,
429
+ error: {
430
+ code: -32601,
431
+ message: `Method not found: ${method}`,
432
+ },
433
+ };
434
+ }
435
+ }
436
+ }
@@ -0,0 +1,52 @@
1
+ import { describe, expect, spyOn, test } from 'bun:test';
2
+ import { withRetry } from './retry';
3
+
4
+ describe('withRetry', () => {
5
+ test('should return result if fn succeeds on first try', async () => {
6
+ const fn = async () => 'success';
7
+ const result = await withRetry(fn, { count: 3, backoff: 'linear' });
8
+ expect(result).toBe('success');
9
+ });
10
+
11
+ test('should retry and succeed', async () => {
12
+ let attempts = 0;
13
+ const fn = async () => {
14
+ attempts++;
15
+ if (attempts < 3) throw new Error('fail');
16
+ return 'success';
17
+ };
18
+
19
+ // Use a small delay for tests if possible, but retry.ts has hardcoded 1000ms base delay.
20
+ // This test might take a few seconds.
21
+ const result = await withRetry(fn, { count: 3, backoff: 'linear' });
22
+ expect(result).toBe('success');
23
+ expect(attempts).toBe(3);
24
+ }, 10000); // 10s timeout
25
+
26
+ test('should throw after exhausting retries', async () => {
27
+ let attempts = 0;
28
+ const fn = async () => {
29
+ attempts++;
30
+ throw new Error('fail');
31
+ };
32
+
33
+ await expect(withRetry(fn, { count: 2, backoff: 'linear' })).rejects.toThrow('fail');
34
+ expect(attempts).toBe(3); // 1 original + 2 retries
35
+ }, 10000);
36
+
37
+ test('should call onRetry callback', async () => {
38
+ let attempts = 0;
39
+ const onRetry = (attempt: number, error: Error) => {
40
+ expect(attempt).toBeGreaterThan(0);
41
+ expect(error.message).toBe('fail');
42
+ };
43
+
44
+ const fn = async () => {
45
+ attempts++;
46
+ if (attempts < 2) throw new Error('fail');
47
+ return 'success';
48
+ };
49
+
50
+ await withRetry(fn, { count: 1, backoff: 'linear' }, onRetry);
51
+ }, 5000);
52
+ });
@@ -0,0 +1,58 @@
1
+ import type { RetryConfig } from '../parser/schema.ts';
2
+
3
+ /**
4
+ * Calculate backoff delay in milliseconds
5
+ */
6
+ function calculateBackoff(
7
+ attempt: number,
8
+ backoff: 'linear' | 'exponential',
9
+ baseDelay = 1000
10
+ ): number {
11
+ if (backoff === 'exponential') {
12
+ return baseDelay * 2 ** attempt;
13
+ }
14
+ // Linear backoff
15
+ return baseDelay * (attempt + 1);
16
+ }
17
+
18
+ /**
19
+ * Sleep for a given duration
20
+ */
21
+ function sleep(ms: number): Promise<void> {
22
+ return new Promise((resolve) => setTimeout(resolve, ms));
23
+ }
24
+
25
+ /**
26
+ * Execute a function with retry logic
27
+ */
28
+ export async function withRetry<T>(
29
+ fn: () => Promise<T>,
30
+ retry?: RetryConfig,
31
+ onRetry?: (attempt: number, error: Error) => void
32
+ ): Promise<T> {
33
+ const maxRetries = retry?.count || 0;
34
+ const backoffType = retry?.backoff || 'linear';
35
+ const baseDelay = retry?.baseDelay ?? 1000;
36
+
37
+ let lastError: Error | undefined;
38
+
39
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
40
+ try {
41
+ return await fn();
42
+ } catch (error) {
43
+ lastError = error instanceof Error ? error : new Error(String(error));
44
+
45
+ // Don't retry if we've exhausted attempts
46
+ if (attempt >= maxRetries) {
47
+ break;
48
+ }
49
+
50
+ // Calculate delay and wait before retry
51
+ const delay = calculateBackoff(attempt, backoffType, baseDelay);
52
+ onRetry?.(attempt + 1, lastError);
53
+ await sleep(delay);
54
+ }
55
+ }
56
+
57
+ throw lastError || new Error('Operation failed with no error details');
58
+ }
@@ -0,0 +1,123 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import type { ExpressionContext } from '../expression/evaluator';
3
+ import type { ShellStep } from '../parser/schema';
4
+ import { escapeShellArg, executeShell } from './shell-executor';
5
+
6
+ describe('shell-executor', () => {
7
+ describe('escapeShellArg', () => {
8
+ it('should wrap in single quotes', () => {
9
+ expect(escapeShellArg('hello')).toBe("'hello'");
10
+ });
11
+
12
+ it('should escape single quotes', () => {
13
+ expect(escapeShellArg("don't")).toBe("'don'\\''t'");
14
+ });
15
+ });
16
+
17
+ describe('executeShell', () => {
18
+ const context: ExpressionContext = {
19
+ inputs: {},
20
+ steps: {},
21
+ env: {},
22
+ };
23
+
24
+ it('should execute a simple command', async () => {
25
+ const step: ShellStep = {
26
+ id: 'test',
27
+ type: 'shell',
28
+ run: 'echo "hello world"',
29
+ };
30
+
31
+ const result = await executeShell(step, context);
32
+ expect(result.stdout.trim()).toBe('hello world');
33
+ expect(result.exitCode).toBe(0);
34
+ });
35
+
36
+ it('should evaluate expressions in the command', async () => {
37
+ const step: ShellStep = {
38
+ id: 'test',
39
+ type: 'shell',
40
+ run: 'echo "${{ inputs.name }}"',
41
+ };
42
+ const customContext: ExpressionContext = {
43
+ ...context,
44
+ inputs: { name: 'world' },
45
+ };
46
+
47
+ const result = await executeShell(step, customContext);
48
+ expect(result.stdout.trim()).toBe('world');
49
+ });
50
+
51
+ it('should handle environment variables', async () => {
52
+ const step: ShellStep = {
53
+ id: 'test',
54
+ type: 'shell',
55
+ run: 'echo $TEST_VAR',
56
+ env: {
57
+ TEST_VAR: 'env-value',
58
+ },
59
+ };
60
+
61
+ const result = await executeShell(step, context);
62
+ expect(result.stdout.trim()).toBe('env-value');
63
+ });
64
+
65
+ it('should handle working directory', async () => {
66
+ const step: ShellStep = {
67
+ id: 'test',
68
+ type: 'shell',
69
+ run: 'pwd',
70
+ dir: '/tmp',
71
+ };
72
+
73
+ const result = await executeShell(step, context);
74
+ expect(result.stdout.trim()).toMatch(/\/tmp$/);
75
+ });
76
+
77
+ it('should capture stderr', async () => {
78
+ const step: ShellStep = {
79
+ id: 'test',
80
+ type: 'shell',
81
+ run: 'echo "error" >&2',
82
+ };
83
+
84
+ const result = await executeShell(step, context);
85
+ expect(result.stderr.trim()).toBe('error');
86
+ });
87
+
88
+ it('should handle non-zero exit codes', async () => {
89
+ const step: ShellStep = {
90
+ id: 'test',
91
+ type: 'shell',
92
+ run: 'exit 1',
93
+ };
94
+
95
+ const result = await executeShell(step, context);
96
+ expect(result.exitCode).toBe(1);
97
+ });
98
+
99
+ it('should warn about shell injection risk', async () => {
100
+ const spy = console.warn;
101
+ let warned = false;
102
+ console.warn = (...args: unknown[]) => {
103
+ const msg = args[0];
104
+ if (
105
+ typeof msg === 'string' &&
106
+ msg.includes('WARNING: Command contains shell metacharacters')
107
+ ) {
108
+ warned = true;
109
+ }
110
+ };
111
+
112
+ const step: ShellStep = {
113
+ id: 'test',
114
+ type: 'shell',
115
+ run: 'echo "hello" ; rm -rf /tmp/foo',
116
+ };
117
+
118
+ await executeShell(step, context);
119
+ expect(warned).toBe(true);
120
+ console.warn = spy; // Restore
121
+ });
122
+ });
123
+ });