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
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();