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
package/src/cli.ts
ADDED
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { Command } from 'commander';
|
|
5
|
+
import { WorkflowDb } from './db/workflow-db.ts';
|
|
6
|
+
import { WorkflowParser } from './parser/workflow-parser.ts';
|
|
7
|
+
import { ConfigLoader } from './utils/config-loader.ts';
|
|
8
|
+
import { generateMermaidGraph, renderMermaidAsAscii } from './utils/mermaid.ts';
|
|
9
|
+
import { WorkflowRegistry } from './utils/workflow-registry.ts';
|
|
10
|
+
|
|
11
|
+
const program = new Command();
|
|
12
|
+
|
|
13
|
+
program
|
|
14
|
+
.name('keystone')
|
|
15
|
+
.description('A local-first, declarative, agentic workflow orchestrator')
|
|
16
|
+
.version('0.1.0');
|
|
17
|
+
|
|
18
|
+
// ===== keystone init =====
|
|
19
|
+
program
|
|
20
|
+
.command('init')
|
|
21
|
+
.description('Initialize a new Keystone project')
|
|
22
|
+
.action(() => {
|
|
23
|
+
console.log('🏛️ Initializing Keystone project...\n');
|
|
24
|
+
|
|
25
|
+
// Create directories
|
|
26
|
+
const dirs = ['.keystone', '.keystone/workflows', '.keystone/workflows/agents'];
|
|
27
|
+
for (const dir of dirs) {
|
|
28
|
+
if (!existsSync(dir)) {
|
|
29
|
+
mkdirSync(dir, { recursive: true });
|
|
30
|
+
console.log(`✓ Created ${dir}/`);
|
|
31
|
+
} else {
|
|
32
|
+
console.log(`⊘ ${dir}/ already exists`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Create default config
|
|
37
|
+
const configPath = '.keystone/config.yaml';
|
|
38
|
+
if (!existsSync(configPath)) {
|
|
39
|
+
const defaultConfig = `# Keystone Configuration
|
|
40
|
+
default_provider: openai
|
|
41
|
+
|
|
42
|
+
providers:
|
|
43
|
+
openai:
|
|
44
|
+
type: openai
|
|
45
|
+
base_url: https://api.openai.com/v1
|
|
46
|
+
api_key_env: OPENAI_API_KEY
|
|
47
|
+
default_model: gpt-4o
|
|
48
|
+
anthropic:
|
|
49
|
+
type: anthropic
|
|
50
|
+
base_url: https://api.anthropic.com/v1
|
|
51
|
+
api_key_env: ANTHROPIC_API_KEY
|
|
52
|
+
default_model: claude-3-5-sonnet-20240620
|
|
53
|
+
groq:
|
|
54
|
+
type: openai
|
|
55
|
+
base_url: https://api.groq.com/openai/v1
|
|
56
|
+
api_key_env: GROQ_API_KEY
|
|
57
|
+
default_model: llama-3.3-70b-versatile
|
|
58
|
+
|
|
59
|
+
model_mappings:
|
|
60
|
+
"gpt-*": openai
|
|
61
|
+
"claude-*": anthropic
|
|
62
|
+
"o1-*": openai
|
|
63
|
+
"llama-*": groq
|
|
64
|
+
|
|
65
|
+
storage:
|
|
66
|
+
retention_days: 30
|
|
67
|
+
workflows_directory: workflows
|
|
68
|
+
`;
|
|
69
|
+
writeFileSync(configPath, defaultConfig);
|
|
70
|
+
console.log(`✓ Created ${configPath}`);
|
|
71
|
+
} else {
|
|
72
|
+
console.log(`⊘ ${configPath} already exists`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Create example .env
|
|
76
|
+
const envPath = '.env';
|
|
77
|
+
if (!existsSync(envPath)) {
|
|
78
|
+
const envTemplate = `# API Keys and Secrets
|
|
79
|
+
# OPENAI_API_KEY=sk-...
|
|
80
|
+
# ANTHROPIC_API_KEY=sk-ant-...
|
|
81
|
+
`;
|
|
82
|
+
writeFileSync(envPath, envTemplate);
|
|
83
|
+
console.log(`✓ Created ${envPath}`);
|
|
84
|
+
} else {
|
|
85
|
+
console.log(`⊘ ${envPath} already exists`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
console.log('\n✨ Keystone project initialized!');
|
|
89
|
+
console.log('\nNext steps:');
|
|
90
|
+
console.log(' 1. Add your API keys to .env');
|
|
91
|
+
console.log(' 2. Create a workflow in .keystone/workflows/');
|
|
92
|
+
console.log(' 3. Run: keystone run <workflow>');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ===== keystone validate =====
|
|
96
|
+
program
|
|
97
|
+
.command('validate')
|
|
98
|
+
.description('Validate workflow files')
|
|
99
|
+
.argument('[path]', 'Workflow file or directory to validate (default: .keystone/workflows/)')
|
|
100
|
+
.action(async (pathArg) => {
|
|
101
|
+
const path = pathArg || '.keystone/workflows/';
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
let files: string[] = [];
|
|
105
|
+
if (existsSync(path) && (path.endsWith('.yaml') || path.endsWith('.yml'))) {
|
|
106
|
+
files = [path];
|
|
107
|
+
} else if (existsSync(path)) {
|
|
108
|
+
const glob = new Bun.Glob('**/*.{yaml,yml}');
|
|
109
|
+
for await (const file of glob.scan(path)) {
|
|
110
|
+
files.push(join(path, file));
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
try {
|
|
114
|
+
const resolved = WorkflowRegistry.resolvePath(path);
|
|
115
|
+
files = [resolved];
|
|
116
|
+
} catch {
|
|
117
|
+
console.error(`✗ Path not found: ${path}`);
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (files.length === 0) {
|
|
123
|
+
console.log('⊘ No workflow files found to validate.');
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
console.log(`🔍 Validating ${files.length} workflow(s)...\n`);
|
|
128
|
+
|
|
129
|
+
let successCount = 0;
|
|
130
|
+
let failCount = 0;
|
|
131
|
+
|
|
132
|
+
for (const file of files) {
|
|
133
|
+
try {
|
|
134
|
+
const workflow = WorkflowParser.loadWorkflow(file);
|
|
135
|
+
console.log(` ✓ ${file.padEnd(40)} ${workflow.name} (${workflow.steps.length} steps)`);
|
|
136
|
+
successCount++;
|
|
137
|
+
} catch (error) {
|
|
138
|
+
console.error(
|
|
139
|
+
` ✗ ${file.padEnd(40)} ${error instanceof Error ? error.message : String(error)}`
|
|
140
|
+
);
|
|
141
|
+
failCount++;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
console.log(`\nSummary: ${successCount} passed, ${failCount} failed.`);
|
|
146
|
+
if (failCount > 0) {
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
} catch (error) {
|
|
150
|
+
console.error('✗ Validation failed:', error instanceof Error ? error.message : error);
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ===== keystone graph =====
|
|
156
|
+
program
|
|
157
|
+
.command('graph')
|
|
158
|
+
.description('Visualize a workflow as a Mermaid.js graph')
|
|
159
|
+
.argument('<workflow>', 'Workflow name or path to workflow file')
|
|
160
|
+
.action(async (workflowPath) => {
|
|
161
|
+
try {
|
|
162
|
+
const resolvedPath = WorkflowRegistry.resolvePath(workflowPath);
|
|
163
|
+
const workflow = WorkflowParser.loadWorkflow(resolvedPath);
|
|
164
|
+
const mermaid = generateMermaidGraph(workflow);
|
|
165
|
+
|
|
166
|
+
const ascii = await renderMermaidAsAscii(mermaid);
|
|
167
|
+
if (ascii) {
|
|
168
|
+
console.log(`\n${ascii}\n`);
|
|
169
|
+
} else {
|
|
170
|
+
console.log('\n```mermaid');
|
|
171
|
+
console.log(mermaid);
|
|
172
|
+
console.log('```\n');
|
|
173
|
+
}
|
|
174
|
+
} catch (error) {
|
|
175
|
+
console.error('✗ Failed to generate graph:', error instanceof Error ? error.message : error);
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// ===== keystone run =====
|
|
181
|
+
program
|
|
182
|
+
.command('run')
|
|
183
|
+
.description('Execute a workflow')
|
|
184
|
+
.argument('<workflow>', 'Workflow name or path to workflow file')
|
|
185
|
+
.option('-i, --input <key=value...>', 'Input values')
|
|
186
|
+
.action(async (workflowPath, options) => {
|
|
187
|
+
// Parse inputs
|
|
188
|
+
const inputs: Record<string, unknown> = {};
|
|
189
|
+
if (options.input) {
|
|
190
|
+
for (const pair of options.input) {
|
|
191
|
+
const index = pair.indexOf('=');
|
|
192
|
+
if (index > 0) {
|
|
193
|
+
const key = pair.slice(0, index);
|
|
194
|
+
const value = pair.slice(index + 1);
|
|
195
|
+
// Try to parse as JSON, otherwise use as string
|
|
196
|
+
try {
|
|
197
|
+
inputs[key] = JSON.parse(value);
|
|
198
|
+
} catch {
|
|
199
|
+
inputs[key] = value;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Load and validate workflow
|
|
206
|
+
try {
|
|
207
|
+
const resolvedPath = WorkflowRegistry.resolvePath(workflowPath);
|
|
208
|
+
const workflow = WorkflowParser.loadWorkflow(resolvedPath);
|
|
209
|
+
|
|
210
|
+
// Auto-prune old runs
|
|
211
|
+
try {
|
|
212
|
+
const config = ConfigLoader.load();
|
|
213
|
+
const db = new WorkflowDb();
|
|
214
|
+
const deleted = await db.pruneRuns(config.storage.retention_days);
|
|
215
|
+
if (deleted > 0) {
|
|
216
|
+
await db.vacuum();
|
|
217
|
+
}
|
|
218
|
+
db.close();
|
|
219
|
+
} catch (error) {
|
|
220
|
+
// Non-fatal
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Import WorkflowRunner dynamically
|
|
224
|
+
const { WorkflowRunner } = await import('./runner/workflow-runner.ts');
|
|
225
|
+
const runner = new WorkflowRunner(workflow, { inputs });
|
|
226
|
+
|
|
227
|
+
const outputs = await runner.run();
|
|
228
|
+
|
|
229
|
+
if (Object.keys(outputs).length > 0) {
|
|
230
|
+
console.log('Outputs:');
|
|
231
|
+
console.log(JSON.stringify(runner.redact(outputs), null, 2));
|
|
232
|
+
}
|
|
233
|
+
} catch (error) {
|
|
234
|
+
console.error(
|
|
235
|
+
'✗ Failed to execute workflow:',
|
|
236
|
+
error instanceof Error ? error.message : error
|
|
237
|
+
);
|
|
238
|
+
process.exit(1);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// ===== keystone resume =====
|
|
243
|
+
program
|
|
244
|
+
.command('resume')
|
|
245
|
+
.description('Resume a paused or failed workflow run')
|
|
246
|
+
.argument('<run_id>', 'Run ID to resume')
|
|
247
|
+
.option('-w, --workflow <path>', 'Path to workflow file (auto-detected if not specified)')
|
|
248
|
+
.action(async (runId, options) => {
|
|
249
|
+
try {
|
|
250
|
+
const config = ConfigLoader.load();
|
|
251
|
+
const db = new WorkflowDb();
|
|
252
|
+
|
|
253
|
+
// Auto-prune old runs
|
|
254
|
+
try {
|
|
255
|
+
const deleted = await db.pruneRuns(config.storage.retention_days);
|
|
256
|
+
if (deleted > 0) {
|
|
257
|
+
await db.vacuum();
|
|
258
|
+
}
|
|
259
|
+
} catch (error) {
|
|
260
|
+
// Non-fatal
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Load run from database to get workflow name
|
|
264
|
+
const run = db.getRun(runId);
|
|
265
|
+
|
|
266
|
+
if (!run) {
|
|
267
|
+
console.error(`✗ Run not found: ${runId}`);
|
|
268
|
+
db.close();
|
|
269
|
+
process.exit(1);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
console.log(`Found run: ${run.workflow_name} (status: ${run.status})`);
|
|
273
|
+
|
|
274
|
+
// Determine workflow file path
|
|
275
|
+
let workflowPath = options.workflow;
|
|
276
|
+
|
|
277
|
+
if (!workflowPath) {
|
|
278
|
+
try {
|
|
279
|
+
workflowPath = WorkflowRegistry.resolvePath(run.workflow_name);
|
|
280
|
+
} catch (error) {
|
|
281
|
+
console.error(
|
|
282
|
+
`✗ Could not find workflow file for '${run.workflow_name}'.\n Use --workflow <path> to specify the path manually.`
|
|
283
|
+
);
|
|
284
|
+
db.close();
|
|
285
|
+
process.exit(1);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
console.log(`Loading workflow from: ${workflowPath}\n`);
|
|
290
|
+
|
|
291
|
+
// Close DB before loading workflow (will be reopened by runner)
|
|
292
|
+
db.close();
|
|
293
|
+
|
|
294
|
+
// Load and validate workflow
|
|
295
|
+
const workflow = WorkflowParser.loadWorkflow(workflowPath);
|
|
296
|
+
|
|
297
|
+
// Import WorkflowRunner dynamically
|
|
298
|
+
const { WorkflowRunner } = await import('./runner/workflow-runner.ts');
|
|
299
|
+
const runner = new WorkflowRunner(workflow, { resumeRunId: runId });
|
|
300
|
+
|
|
301
|
+
const outputs = await runner.run();
|
|
302
|
+
|
|
303
|
+
if (Object.keys(outputs).length > 0) {
|
|
304
|
+
console.log('Outputs:');
|
|
305
|
+
console.log(JSON.stringify(runner.redact(outputs), null, 2));
|
|
306
|
+
}
|
|
307
|
+
} catch (error) {
|
|
308
|
+
console.error('✗ Failed to resume workflow:', error instanceof Error ? error.message : error);
|
|
309
|
+
process.exit(1);
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// ===== keystone workflows =====
|
|
314
|
+
program
|
|
315
|
+
.command('workflows')
|
|
316
|
+
.description('List available workflows')
|
|
317
|
+
.action(() => {
|
|
318
|
+
try {
|
|
319
|
+
const workflows = WorkflowRegistry.listWorkflows();
|
|
320
|
+
if (workflows.length === 0) {
|
|
321
|
+
console.log('No workflows found.');
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
console.log('\nAvailable workflows:\n');
|
|
326
|
+
for (const w of workflows) {
|
|
327
|
+
const description = w.description ? ` - ${w.description}` : '';
|
|
328
|
+
console.log(` ${w.name.padEnd(25)}${description}`);
|
|
329
|
+
}
|
|
330
|
+
console.log();
|
|
331
|
+
} catch (error) {
|
|
332
|
+
console.error('✗ Failed to list workflows:', error instanceof Error ? error.message : error);
|
|
333
|
+
process.exit(1);
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// ===== keystone history =====
|
|
338
|
+
program
|
|
339
|
+
.command('history')
|
|
340
|
+
.description('List recent workflow runs')
|
|
341
|
+
.option('-n, --limit <number>', 'Number of runs to show', '20')
|
|
342
|
+
.action((options) => {
|
|
343
|
+
try {
|
|
344
|
+
const db = new WorkflowDb();
|
|
345
|
+
const runs = db.listRuns(Number.parseInt(options.limit));
|
|
346
|
+
|
|
347
|
+
if (runs.length === 0) {
|
|
348
|
+
console.log('No workflow runs found.');
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
console.log('\nRecent workflow runs:\n');
|
|
353
|
+
for (const run of runs) {
|
|
354
|
+
const status = run.status.toUpperCase().padEnd(10);
|
|
355
|
+
const date = new Date(run.started_at).toLocaleString();
|
|
356
|
+
console.log(
|
|
357
|
+
`${run.id.substring(0, 8)} ${status} ${run.workflow_name.padEnd(20)} ${date}`
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
db.close();
|
|
362
|
+
} catch (error) {
|
|
363
|
+
console.error('✗ Failed to list runs:', error instanceof Error ? error.message : error);
|
|
364
|
+
process.exit(1);
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// ===== keystone logs =====
|
|
369
|
+
program
|
|
370
|
+
.command('logs')
|
|
371
|
+
.description('Show logs for a workflow run')
|
|
372
|
+
.argument('<run_id>', 'Run ID')
|
|
373
|
+
.action((runId) => {
|
|
374
|
+
try {
|
|
375
|
+
const db = new WorkflowDb();
|
|
376
|
+
const run = db.getRun(runId);
|
|
377
|
+
|
|
378
|
+
if (!run) {
|
|
379
|
+
console.error(`✗ Run not found: ${runId}`);
|
|
380
|
+
process.exit(1);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
console.log(`\n📋 Workflow: ${run.workflow_name}`);
|
|
384
|
+
console.log(`Status: ${run.status}`);
|
|
385
|
+
console.log(`Started: ${new Date(run.started_at).toLocaleString()}`);
|
|
386
|
+
|
|
387
|
+
const steps = db.getStepsByRun(runId);
|
|
388
|
+
if (steps.length > 0) {
|
|
389
|
+
console.log('\nSteps:');
|
|
390
|
+
for (const step of steps) {
|
|
391
|
+
const status = step.status.toUpperCase().padEnd(10);
|
|
392
|
+
console.log(` ${step.step_id.padEnd(20)} ${status}`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
db.close();
|
|
397
|
+
} catch (error) {
|
|
398
|
+
console.error('✗ Failed to show logs:', error instanceof Error ? error.message : error);
|
|
399
|
+
process.exit(1);
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// ===== keystone prune =====
|
|
404
|
+
program
|
|
405
|
+
.command('prune')
|
|
406
|
+
.description('Delete old workflow runs from the database')
|
|
407
|
+
.option('--days <days>', 'Delete runs older than this many days', '7')
|
|
408
|
+
.action(async (options) => {
|
|
409
|
+
try {
|
|
410
|
+
const days = Number.parseInt(options.days, 10);
|
|
411
|
+
if (Number.isNaN(days) || days < 0) {
|
|
412
|
+
console.error('✗ Invalid days value. Must be a positive number.');
|
|
413
|
+
process.exit(1);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const db = new WorkflowDb();
|
|
417
|
+
const deleted = await db.pruneRuns(days);
|
|
418
|
+
if (deleted > 0) {
|
|
419
|
+
await db.vacuum();
|
|
420
|
+
}
|
|
421
|
+
db.close();
|
|
422
|
+
|
|
423
|
+
console.log(`✓ Deleted ${deleted} workflow run(s) older than ${days} days`);
|
|
424
|
+
} catch (error) {
|
|
425
|
+
console.error('✗ Failed to prune runs:', error instanceof Error ? error.message : error);
|
|
426
|
+
process.exit(1);
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// ===== keystone ui =====
|
|
431
|
+
program
|
|
432
|
+
.command('ui')
|
|
433
|
+
.description('Open the TUI dashboard')
|
|
434
|
+
.action(async () => {
|
|
435
|
+
const { startDashboard } = await import('./ui/dashboard.tsx');
|
|
436
|
+
startDashboard();
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
// ===== keystone mcp =====
|
|
440
|
+
program
|
|
441
|
+
.command('mcp')
|
|
442
|
+
.description('Start the Model Context Protocol server')
|
|
443
|
+
.action(async () => {
|
|
444
|
+
const { MCPServer } = await import('./runner/mcp-server.ts');
|
|
445
|
+
|
|
446
|
+
if (process.stdin.isTTY) {
|
|
447
|
+
const DIM = '\x1b[2m';
|
|
448
|
+
const CYAN = '\x1b[36m';
|
|
449
|
+
const RESET = '\x1b[0m';
|
|
450
|
+
|
|
451
|
+
process.stderr.write(`${CYAN}🏛️ Keystone MCP Server${RESET}\n\n`);
|
|
452
|
+
process.stderr.write(
|
|
453
|
+
'To add this server to Claude Desktop, include this in your configuration:\n'
|
|
454
|
+
);
|
|
455
|
+
process.stderr.write(
|
|
456
|
+
`${DIM}${JSON.stringify(
|
|
457
|
+
{
|
|
458
|
+
mcpServers: {
|
|
459
|
+
keystone: {
|
|
460
|
+
command: 'keystone',
|
|
461
|
+
args: ['mcp'],
|
|
462
|
+
},
|
|
463
|
+
},
|
|
464
|
+
},
|
|
465
|
+
null,
|
|
466
|
+
2
|
|
467
|
+
)}${RESET}\n`
|
|
468
|
+
);
|
|
469
|
+
process.stderr.write(
|
|
470
|
+
`\nStatus: ${CYAN}Running...${RESET} ${DIM}(Press Ctrl+C to stop)${RESET}\n`
|
|
471
|
+
);
|
|
472
|
+
} else {
|
|
473
|
+
process.stderr.write('Keystone MCP Server started\n');
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const server = new MCPServer();
|
|
477
|
+
await server.start();
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// ===== keystone config =====
|
|
481
|
+
program
|
|
482
|
+
.command('config')
|
|
483
|
+
.description('Show current configuration')
|
|
484
|
+
.action(async () => {
|
|
485
|
+
const { ConfigLoader } = await import('./utils/config-loader.ts');
|
|
486
|
+
try {
|
|
487
|
+
const config = ConfigLoader.load();
|
|
488
|
+
console.log('\n🏛️ Keystone Configuration:');
|
|
489
|
+
console.log(JSON.stringify(config, null, 2));
|
|
490
|
+
} catch (error) {
|
|
491
|
+
console.error('✗ Failed to load config:', error instanceof Error ? error.message : error);
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
// ===== keystone auth =====
|
|
496
|
+
const auth = program.command('auth').description('Authentication management');
|
|
497
|
+
|
|
498
|
+
auth
|
|
499
|
+
.command('login')
|
|
500
|
+
.description('Login to an authentication provider')
|
|
501
|
+
.option('-p, --provider <provider>', 'Authentication provider', 'github')
|
|
502
|
+
.action(async (options) => {
|
|
503
|
+
const { AuthManager } = await import('./utils/auth-manager.ts');
|
|
504
|
+
const provider = options.provider.toLowerCase();
|
|
505
|
+
|
|
506
|
+
if (provider !== 'github' && provider !== 'copilot') {
|
|
507
|
+
console.error(`✗ Unsupported provider: ${provider}`);
|
|
508
|
+
process.exit(1);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
console.log(`🏛️ ${provider === 'copilot' ? 'GitHub Copilot' : 'GitHub'} Login\n`);
|
|
512
|
+
|
|
513
|
+
try {
|
|
514
|
+
// Step 1: Request device code
|
|
515
|
+
const deviceCodeResponse = await fetch('https://github.com/login/device/code', {
|
|
516
|
+
method: 'POST',
|
|
517
|
+
headers: {
|
|
518
|
+
'Content-Type': 'application/json',
|
|
519
|
+
Accept: 'application/json',
|
|
520
|
+
},
|
|
521
|
+
body: JSON.stringify({
|
|
522
|
+
client_id: '01ab8ac9400c4e429b23',
|
|
523
|
+
scope: 'read:user',
|
|
524
|
+
}),
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
if (!deviceCodeResponse.ok) {
|
|
528
|
+
throw new Error(`GitHub API error: ${deviceCodeResponse.statusText}`);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const { device_code, user_code, verification_uri, interval } =
|
|
532
|
+
(await deviceCodeResponse.json()) as {
|
|
533
|
+
device_code: string;
|
|
534
|
+
user_code: string;
|
|
535
|
+
verification_uri: string;
|
|
536
|
+
interval: number;
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
console.log(`1. Visit: ${verification_uri}`);
|
|
540
|
+
console.log(`2. Enter code: ${user_code}\n`);
|
|
541
|
+
console.log('Waiting for authorization...');
|
|
542
|
+
|
|
543
|
+
// Step 3: Poll for access token
|
|
544
|
+
const poll = async (): Promise<string> => {
|
|
545
|
+
while (true) {
|
|
546
|
+
await new Promise((resolve) => setTimeout(resolve, interval * 1000));
|
|
547
|
+
|
|
548
|
+
const response = await fetch('https://github.com/login/oauth/access_token', {
|
|
549
|
+
method: 'POST',
|
|
550
|
+
headers: {
|
|
551
|
+
'Content-Type': 'application/json',
|
|
552
|
+
Accept: 'application/json',
|
|
553
|
+
},
|
|
554
|
+
body: JSON.stringify({
|
|
555
|
+
client_id: '01ab8ac9400c4e429b23',
|
|
556
|
+
device_code,
|
|
557
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
558
|
+
}),
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
const data = (await response.json()) as {
|
|
562
|
+
access_token?: string;
|
|
563
|
+
error?: string;
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
if (data.access_token) {
|
|
567
|
+
return data.access_token;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
if (data.error === 'authorization_pending') {
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
throw new Error(`GitHub error: ${data.error}`);
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
const accessToken = await poll();
|
|
579
|
+
AuthManager.save({ github_token: accessToken });
|
|
580
|
+
|
|
581
|
+
console.log(
|
|
582
|
+
`\n✨ Successfully logged into ${provider === 'copilot' ? 'GitHub Copilot' : 'GitHub'}!`
|
|
583
|
+
);
|
|
584
|
+
} catch (error) {
|
|
585
|
+
console.error('\n✗ Login failed:', error instanceof Error ? error.message : error);
|
|
586
|
+
process.exit(1);
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
auth
|
|
591
|
+
.command('status')
|
|
592
|
+
.description('Show authentication status')
|
|
593
|
+
.option('-p, --provider <provider>', 'Authentication provider')
|
|
594
|
+
.action(async (options) => {
|
|
595
|
+
const { AuthManager } = await import('./utils/auth-manager.ts');
|
|
596
|
+
const auth = AuthManager.load();
|
|
597
|
+
const provider = options.provider?.toLowerCase();
|
|
598
|
+
|
|
599
|
+
console.log('\n🏛️ Authentication Status:');
|
|
600
|
+
|
|
601
|
+
if (!provider || provider === 'github' || provider === 'copilot') {
|
|
602
|
+
if (auth.github_token) {
|
|
603
|
+
console.log(' ✓ Logged into GitHub');
|
|
604
|
+
if (auth.copilot_expires_at) {
|
|
605
|
+
const expires = new Date(auth.copilot_expires_at * 1000);
|
|
606
|
+
console.log(` ✓ Copilot session expires: ${expires.toLocaleString()}`);
|
|
607
|
+
}
|
|
608
|
+
} else if (provider) {
|
|
609
|
+
console.log(
|
|
610
|
+
` ⊘ Not logged into GitHub. Run "keystone auth login --provider github" to authenticate.`
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (!auth.github_token && !provider) {
|
|
616
|
+
console.log(' ⊘ Not logged in. Run "keystone auth login" to authenticate.');
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
auth
|
|
621
|
+
.command('logout')
|
|
622
|
+
.description('Logout and clear authentication tokens')
|
|
623
|
+
.option('-p, --provider <provider>', 'Authentication provider')
|
|
624
|
+
.action(async (options) => {
|
|
625
|
+
const { AuthManager } = await import('./utils/auth-manager.ts');
|
|
626
|
+
const provider = options.provider?.toLowerCase();
|
|
627
|
+
|
|
628
|
+
if (!provider || provider === 'github' || provider === 'copilot') {
|
|
629
|
+
AuthManager.save({
|
|
630
|
+
github_token: undefined,
|
|
631
|
+
copilot_token: undefined,
|
|
632
|
+
copilot_expires_at: undefined,
|
|
633
|
+
});
|
|
634
|
+
console.log('✓ Successfully logged out of GitHub.');
|
|
635
|
+
} else {
|
|
636
|
+
console.error(`✗ Unknown provider: ${provider}`);
|
|
637
|
+
process.exit(1);
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
// ===== Internal Helper Commands (Hidden) =====
|
|
642
|
+
program.command('_list-workflows', { hidden: true }).action(() => {
|
|
643
|
+
const workflows = WorkflowRegistry.listWorkflows();
|
|
644
|
+
for (const w of workflows) {
|
|
645
|
+
console.log(w.name);
|
|
646
|
+
}
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
program.command('_list-runs', { hidden: true }).action(() => {
|
|
650
|
+
try {
|
|
651
|
+
const db = new WorkflowDb();
|
|
652
|
+
const runs = db.listRuns(50);
|
|
653
|
+
for (const run of runs) {
|
|
654
|
+
console.log(run.id);
|
|
655
|
+
}
|
|
656
|
+
db.close();
|
|
657
|
+
} catch (e) {
|
|
658
|
+
// Ignore errors in helper
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
// ===== keystone completion =====
|
|
663
|
+
program
|
|
664
|
+
.command('completion')
|
|
665
|
+
.description('Generate shell completion script')
|
|
666
|
+
.argument('[shell]', 'Shell type (zsh, bash)', 'zsh')
|
|
667
|
+
.action((shell) => {
|
|
668
|
+
if (shell === 'zsh') {
|
|
669
|
+
console.log(`#compdef keystone
|
|
670
|
+
|
|
671
|
+
if [[ -n $ZSH_VERSION ]]; then
|
|
672
|
+
compdef _keystone keystone
|
|
673
|
+
fi
|
|
674
|
+
|
|
675
|
+
_keystone() {
|
|
676
|
+
local line state
|
|
677
|
+
|
|
678
|
+
_arguments -C \\
|
|
679
|
+
"1: :->command" \\
|
|
680
|
+
"*:: :->args"
|
|
681
|
+
|
|
682
|
+
case $state in
|
|
683
|
+
command)
|
|
684
|
+
local -a commands
|
|
685
|
+
commands=(
|
|
686
|
+
'init:Initialize a new Keystone project'
|
|
687
|
+
'validate:Validate workflow files'
|
|
688
|
+
'graph:Visualize a workflow as a Mermaid.js graph'
|
|
689
|
+
'run:Execute a workflow'
|
|
690
|
+
'resume:Resume a paused or failed workflow run'
|
|
691
|
+
'workflows:List available workflows'
|
|
692
|
+
'history:List recent workflow runs'
|
|
693
|
+
'logs:Show logs for a workflow run'
|
|
694
|
+
'prune:Delete old workflow runs from the database'
|
|
695
|
+
'ui:Open the TUI dashboard'
|
|
696
|
+
'mcp:Start the Model Context Protocol server'
|
|
697
|
+
'config:Show current configuration'
|
|
698
|
+
'auth:Authentication management'
|
|
699
|
+
'completion:Generate shell completion script'
|
|
700
|
+
)
|
|
701
|
+
_describe -t commands 'keystone command' commands
|
|
702
|
+
;;
|
|
703
|
+
args)
|
|
704
|
+
case $words[1] in
|
|
705
|
+
run)
|
|
706
|
+
_arguments \\
|
|
707
|
+
'(-i --input)'{-i,--input}'[Input values]:key=value' \\
|
|
708
|
+
':workflow:__keystone_workflows'
|
|
709
|
+
;;
|
|
710
|
+
graph)
|
|
711
|
+
_arguments ':workflow:__keystone_workflows'
|
|
712
|
+
;;
|
|
713
|
+
validate)
|
|
714
|
+
_arguments ':path:_files'
|
|
715
|
+
;;
|
|
716
|
+
resume|logs)
|
|
717
|
+
_arguments ':run_id:__keystone_runs'
|
|
718
|
+
;;
|
|
719
|
+
auth)
|
|
720
|
+
local -a auth_commands
|
|
721
|
+
auth_commands=(
|
|
722
|
+
'login:Login to an authentication provider'
|
|
723
|
+
'status:Show authentication status'
|
|
724
|
+
'logout:Logout and clear authentication tokens'
|
|
725
|
+
)
|
|
726
|
+
_describe -t auth_commands 'auth command' auth_commands
|
|
727
|
+
;;
|
|
728
|
+
esac
|
|
729
|
+
;;
|
|
730
|
+
esac
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
__keystone_workflows() {
|
|
734
|
+
local -a workflows
|
|
735
|
+
workflows=($(keystone _list-workflows 2>/dev/null))
|
|
736
|
+
_describe -t workflows 'workflow' workflows
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
__keystone_runs() {
|
|
740
|
+
local -a runs
|
|
741
|
+
runs=($(keystone _list-runs 2>/dev/null))
|
|
742
|
+
_describe -t runs 'run_id' runs
|
|
743
|
+
}
|
|
744
|
+
`);
|
|
745
|
+
} else if (shell === 'bash') {
|
|
746
|
+
console.log(`_keystone_completion() {
|
|
747
|
+
local cur prev opts
|
|
748
|
+
COMPREPLY=()
|
|
749
|
+
cur="${COMP_WORDS[COMP_CWORD]}"
|
|
750
|
+
prev="${COMP_WORDS[COMP_CWORD - 1]}"
|
|
751
|
+
opts="init validate graph run resume workflows history logs prune ui mcp config auth completion"
|
|
752
|
+
|
|
753
|
+
case "${prev}" in
|
|
754
|
+
run|graph)
|
|
755
|
+
local workflows=$(keystone _list-workflows 2>/dev/null)
|
|
756
|
+
COMPREPLY=( $(compgen -W "\${workflows}" -- \${cur}) )
|
|
757
|
+
return 0
|
|
758
|
+
;;
|
|
759
|
+
resume|logs)
|
|
760
|
+
local runs=$(keystone _list-runs 2>/dev/null)
|
|
761
|
+
COMPREPLY=( $(compgen -W "\${runs}" -- \${cur}) )
|
|
762
|
+
return 0
|
|
763
|
+
;;
|
|
764
|
+
esac
|
|
765
|
+
|
|
766
|
+
COMPREPLY=( $(compgen -W "\${opts}" -- \${cur}) )
|
|
767
|
+
}
|
|
768
|
+
complete -F _keystone_completion keystone`);
|
|
769
|
+
} else {
|
|
770
|
+
console.error(`✗ Unsupported shell: ${shell}. Supported: zsh, bash`);
|
|
771
|
+
process.exit(1);
|
|
772
|
+
}
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
program.parse();
|