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,85 @@
1
+ import { MCPClient } from './mcp-client';
2
+ import { ConfigLoader } from '../utils/config-loader';
3
+ import type { Logger } from './workflow-runner';
4
+
5
+ export interface MCPServerConfig {
6
+ name: string;
7
+ command: string;
8
+ args?: string[];
9
+ env?: Record<string, string>;
10
+ }
11
+
12
+ export class MCPManager {
13
+ private clients: Map<string, MCPClient> = new Map();
14
+ private sharedServers: Map<string, MCPServerConfig> = new Map();
15
+
16
+ constructor() {
17
+ this.loadGlobalConfig();
18
+ }
19
+
20
+ private loadGlobalConfig() {
21
+ const config = ConfigLoader.load();
22
+ if (config.mcp_servers) {
23
+ for (const [name, server] of Object.entries(config.mcp_servers)) {
24
+ this.sharedServers.set(name, {
25
+ name,
26
+ command: server.command,
27
+ args: server.args,
28
+ env: server.env,
29
+ });
30
+ }
31
+ }
32
+ }
33
+
34
+ async getClient(
35
+ serverRef: string | MCPServerConfig,
36
+ logger: Logger = console
37
+ ): Promise<MCPClient | undefined> {
38
+ let config: MCPServerConfig;
39
+
40
+ if (typeof serverRef === 'string') {
41
+ const shared = this.sharedServers.get(serverRef);
42
+ if (!shared) {
43
+ logger.error(` ✗ Global MCP server not found: ${serverRef}`);
44
+ return undefined;
45
+ }
46
+ config = shared;
47
+ } else {
48
+ config = serverRef;
49
+ }
50
+
51
+ const key = this.getServerKey(config);
52
+ if (this.clients.has(key)) {
53
+ return this.clients.get(key);
54
+ }
55
+
56
+ logger.log(` 🔌 Connecting to MCP server: ${config.name}`);
57
+ const client = new MCPClient(config.command, config.args || [], config.env || {});
58
+ try {
59
+ await client.initialize();
60
+ this.clients.set(key, client);
61
+ return client;
62
+ } catch (error) {
63
+ logger.error(
64
+ ` ✗ Failed to connect to MCP server ${config.name}: ${error instanceof Error ? error.message : String(error)}`
65
+ );
66
+ client.stop();
67
+ return undefined;
68
+ }
69
+ }
70
+
71
+ private getServerKey(config: MCPServerConfig): string {
72
+ return config.name;
73
+ }
74
+
75
+ getGlobalServers(): MCPServerConfig[] {
76
+ return Array.from(this.sharedServers.values());
77
+ }
78
+
79
+ async stopAll() {
80
+ for (const client of this.clients.values()) {
81
+ client.stop();
82
+ }
83
+ this.clients.clear();
84
+ }
85
+ }
@@ -0,0 +1,242 @@
1
+ import { beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
2
+ import { WorkflowDb } from '../db/workflow-db';
3
+ import { WorkflowParser } from '../parser/workflow-parser';
4
+ import { WorkflowRegistry } from '../utils/workflow-registry';
5
+ import { MCPServer } from './mcp-server';
6
+ import { WorkflowSuspendedError } from './step-executor';
7
+ import { WorkflowRunner } from './workflow-runner';
8
+
9
+ describe('MCPServer', () => {
10
+ let db: WorkflowDb;
11
+ let server: MCPServer;
12
+
13
+ beforeEach(() => {
14
+ db = new WorkflowDb(':memory:');
15
+ server = new MCPServer(db);
16
+ mock.restore();
17
+ });
18
+
19
+ const handleMessage = (msg: unknown) => {
20
+ // @ts-ignore
21
+ return server.handleMessage(msg);
22
+ };
23
+
24
+ it('should handle initialize request', async () => {
25
+ const response = await handleMessage({
26
+ jsonrpc: '2.0',
27
+ id: 1,
28
+ method: 'initialize',
29
+ });
30
+
31
+ expect(response.result.serverInfo.name).toBe('keystone-mcp');
32
+ });
33
+
34
+ it('should list tools', async () => {
35
+ const response = await handleMessage({
36
+ jsonrpc: '2.0',
37
+ id: 2,
38
+ method: 'tools/list',
39
+ });
40
+
41
+ expect(response.result.tools).toHaveLength(5);
42
+ // @ts-ignore
43
+ expect(response.result.tools.map((t) => t.name)).toContain('run_workflow');
44
+ });
45
+
46
+ it('should call list_workflows tool', async () => {
47
+ spyOn(WorkflowRegistry, 'listWorkflows').mockReturnValue([
48
+ { name: 'test-wf', description: 'Test Workflow' },
49
+ ]);
50
+
51
+ const response = await handleMessage({
52
+ jsonrpc: '2.0',
53
+ id: 3,
54
+ method: 'tools/call',
55
+ params: { name: 'list_workflows', arguments: {} },
56
+ });
57
+
58
+ expect(response.result.content[0].text).toContain('test-wf');
59
+ });
60
+
61
+ it('should call run_workflow tool successfully', async () => {
62
+ spyOn(WorkflowRegistry, 'resolvePath').mockReturnValue('test.yaml');
63
+ // @ts-ignore
64
+ spyOn(WorkflowParser, 'loadWorkflow').mockReturnValue({
65
+ name: 'test-wf',
66
+ steps: [],
67
+ });
68
+
69
+ // Mock WorkflowRunner
70
+ const mockRun = mock(() => Promise.resolve({ result: 'ok' }));
71
+ // @ts-ignore
72
+ spyOn(WorkflowRunner.prototype, 'run').mockImplementation(mockRun);
73
+
74
+ const response = await handleMessage({
75
+ jsonrpc: '2.0',
76
+ id: 4,
77
+ method: 'tools/call',
78
+ params: {
79
+ name: 'run_workflow',
80
+ arguments: { workflow_name: 'test-wf', inputs: {} },
81
+ },
82
+ });
83
+
84
+ expect(JSON.parse(response.result.content[0].text).status).toBe('success');
85
+ });
86
+
87
+ it('should handle run_workflow failure', async () => {
88
+ spyOn(WorkflowRegistry, 'resolvePath').mockReturnValue('test.yaml');
89
+ // @ts-ignore
90
+ spyOn(WorkflowParser, 'loadWorkflow').mockReturnValue({
91
+ name: 'test-wf',
92
+ steps: [],
93
+ });
94
+
95
+ spyOn(WorkflowRunner.prototype, 'run').mockRejectedValue(new Error('workflow failed'));
96
+
97
+ const response = await handleMessage({
98
+ jsonrpc: '2.0',
99
+ id: 5,
100
+ method: 'tools/call',
101
+ params: {
102
+ name: 'run_workflow',
103
+ arguments: { workflow_name: 'test-wf' },
104
+ },
105
+ });
106
+
107
+ expect(response.result.isError).toBe(true);
108
+ expect(response.result.content[0].text).toContain('Workflow failed');
109
+ });
110
+
111
+ it('should handle workflow suspension in run_workflow', async () => {
112
+ spyOn(WorkflowRegistry, 'resolvePath').mockReturnValue('test.yaml');
113
+ // @ts-ignore
114
+ spyOn(WorkflowParser, 'loadWorkflow').mockReturnValue({
115
+ name: 'test-wf',
116
+ steps: [],
117
+ });
118
+
119
+ const suspendedError = new WorkflowSuspendedError('Input needed', 'step1', 'text');
120
+ spyOn(WorkflowRunner.prototype, 'run').mockRejectedValue(suspendedError);
121
+ spyOn(WorkflowRunner.prototype, 'getRunId').mockReturnValue('run123');
122
+
123
+ const response = await handleMessage({
124
+ jsonrpc: '2.0',
125
+ id: 6,
126
+ method: 'tools/call',
127
+ params: {
128
+ name: 'run_workflow',
129
+ arguments: { workflow_name: 'test-wf' },
130
+ },
131
+ });
132
+
133
+ const result = JSON.parse(response.result.content[0].text);
134
+ expect(result.status).toBe('paused');
135
+ expect(result.run_id).toBe('run123');
136
+ expect(result.message).toBe('Input needed');
137
+ });
138
+
139
+ it('should handle answer_human_input and resume', async () => {
140
+ const runId = 'run-to-resume';
141
+ await db.createRun(runId, 'test-wf', {});
142
+ await db.updateRunStatus(runId, 'paused');
143
+ await db.createStep('step-exec-1', runId, 's1');
144
+
145
+ spyOn(WorkflowRegistry, 'resolvePath').mockReturnValue('test.yaml');
146
+ // @ts-ignore
147
+ spyOn(WorkflowParser, 'loadWorkflow').mockReturnValue({
148
+ name: 'test-wf',
149
+ steps: [{ id: 's1', type: 'human' }],
150
+ });
151
+
152
+ const mockRun = mock(() => Promise.resolve({ result: 'resumed' }));
153
+ // @ts-ignore
154
+ spyOn(WorkflowRunner.prototype, 'run').mockImplementation(mockRun);
155
+
156
+ const response = await handleMessage({
157
+ jsonrpc: '2.0',
158
+ id: 7,
159
+ method: 'tools/call',
160
+ params: {
161
+ name: 'answer_human_input',
162
+ arguments: { run_id: runId, input: 'my response' },
163
+ },
164
+ });
165
+
166
+ expect(JSON.parse(response.result.content[0].text).status).toBe('success');
167
+
168
+ // Verify DB was updated
169
+ const steps = db.getStepsByRun(runId);
170
+ expect(steps[0].status).toBe('success');
171
+ expect(steps[0].output).toBeDefined();
172
+ if (steps[0].output) {
173
+ expect(JSON.parse(steps[0].output)).toBe('my response');
174
+ }
175
+ });
176
+
177
+ it('should call get_run_logs tool with steps', async () => {
178
+ const runId = 'test-run-with-steps';
179
+ await db.createRun(runId, 'test-wf', {});
180
+ await db.createStep('step-1', runId, 's1');
181
+ await db.completeStep('step-1', 'success', { ok: true });
182
+
183
+ const response = await handleMessage({
184
+ jsonrpc: '2.0',
185
+ id: 6,
186
+ method: 'tools/call',
187
+ params: { name: 'get_run_logs', arguments: { run_id: runId } },
188
+ });
189
+
190
+ const summary = JSON.parse(response.result.content[0].text);
191
+ expect(summary.workflow).toBe('test-wf');
192
+ expect(summary.steps).toHaveLength(1);
193
+ expect(summary.steps[0].step).toBe('s1');
194
+ expect(summary.steps[0].output).toEqual({ ok: true });
195
+ });
196
+
197
+ it('should handle unknown tool', async () => {
198
+ const response = await handleMessage({
199
+ jsonrpc: '2.0',
200
+ id: 7,
201
+ method: 'tools/call',
202
+ params: { name: 'unknown_tool', arguments: {} },
203
+ });
204
+
205
+ expect(response.error.message).toContain('Unknown tool');
206
+ });
207
+
208
+ it('should handle unknown method', async () => {
209
+ const response = await handleMessage({
210
+ jsonrpc: '2.0',
211
+ id: 8,
212
+ method: 'unknown_method',
213
+ });
214
+
215
+ expect(response.error.message).toContain('Method not found');
216
+ });
217
+
218
+ it('should start and handle messages from stdin', async () => {
219
+ const writeSpy = spyOn(process.stdout, 'write').mockImplementation(() => true);
220
+ const consoleSpy = spyOn(console, 'error').mockImplementation(() => {});
221
+
222
+ await server.start();
223
+
224
+ // Simulate stdin data
225
+ const message = {
226
+ jsonrpc: '2.0' as const,
227
+ id: 9,
228
+ method: 'initialize',
229
+ };
230
+ process.stdin.emit('data', Buffer.from(`${JSON.stringify(message)}\n`));
231
+
232
+ // Wait for async processing
233
+ await new Promise((resolve) => setTimeout(resolve, 50));
234
+
235
+ expect(writeSpy).toHaveBeenCalled();
236
+ const output = JSON.parse(writeSpy.mock.calls[0][0] as string);
237
+ expect(output.id).toBe(9);
238
+
239
+ writeSpy.mockRestore();
240
+ consoleSpy.mockRestore();
241
+ });
242
+ });