keystone-cli 0.2.0 → 0.3.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 +24 -8
- package/package.json +14 -3
- package/src/cli.ts +108 -18
- package/src/expression/evaluator.test.ts +4 -0
- package/src/expression/evaluator.ts +9 -1
- package/src/parser/agent-parser.ts +11 -4
- package/src/parser/config-schema.ts +11 -0
- package/src/parser/schema.ts +1 -1
- package/src/parser/workflow-parser.ts +5 -4
- package/src/runner/llm-executor.test.ts +174 -81
- package/src/runner/llm-executor.ts +8 -3
- package/src/runner/mcp-client.test.ts +85 -47
- package/src/runner/mcp-client.ts +235 -42
- package/src/runner/mcp-manager.ts +42 -2
- package/src/runner/step-executor.test.ts +2 -2
- package/src/runner/step-executor.ts +21 -7
- package/src/runner/workflow-runner.ts +9 -2
- package/src/utils/auth-manager.test.ts +86 -0
- package/src/utils/auth-manager.ts +89 -0
- package/src/utils/config-loader.test.ts +30 -0
- package/src/utils/config-loader.ts +11 -1
- package/src/utils/mermaid.test.ts +27 -3
package/README.md
CHANGED
|
@@ -80,6 +80,11 @@ Add your API keys to the generated `.env` file:
|
|
|
80
80
|
OPENAI_API_KEY=sk-...
|
|
81
81
|
ANTHROPIC_API_KEY=sk-ant-...
|
|
82
82
|
```
|
|
83
|
+
Alternatively, you can use the built-in authentication management:
|
|
84
|
+
```bash
|
|
85
|
+
keystone auth login openai
|
|
86
|
+
keystone auth login anthropic
|
|
87
|
+
```
|
|
83
88
|
|
|
84
89
|
### 3. Run a Workflow
|
|
85
90
|
```bash
|
|
@@ -175,7 +180,7 @@ You can add any OpenAI-compatible provider (Groq, Together AI, Perplexity, Local
|
|
|
175
180
|
Keystone supports using your GitHub Copilot subscription directly. To authenticate (using the GitHub Device Flow):
|
|
176
181
|
|
|
177
182
|
```bash
|
|
178
|
-
keystone auth login
|
|
183
|
+
keystone auth login github
|
|
179
184
|
```
|
|
180
185
|
|
|
181
186
|
Then, you can use Copilot in your configuration:
|
|
@@ -187,10 +192,18 @@ providers:
|
|
|
187
192
|
default_model: gpt-4o
|
|
188
193
|
```
|
|
189
194
|
|
|
190
|
-
Authentication tokens for Copilot are managed automatically after the initial login.
|
|
195
|
+
Authentication tokens for Copilot are managed automatically after the initial login.
|
|
196
|
+
|
|
197
|
+
### API Key Management
|
|
198
|
+
|
|
199
|
+
For other providers, you can either store API keys in a `.env` file in your project root:
|
|
191
200
|
- `OPENAI_API_KEY`
|
|
192
201
|
- `ANTHROPIC_API_KEY`
|
|
193
202
|
|
|
203
|
+
Or use the `keystone auth login` command to securely store them in your local machine's configuration:
|
|
204
|
+
- `keystone auth login openai`
|
|
205
|
+
- `keystone auth login anthropic`
|
|
206
|
+
|
|
194
207
|
---
|
|
195
208
|
|
|
196
209
|
## 📝 Workflow Example
|
|
@@ -332,10 +345,13 @@ mcp_servers:
|
|
|
332
345
|
command: npx
|
|
333
346
|
args: ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed/directory"]
|
|
334
347
|
|
|
335
|
-
# Remote server (
|
|
348
|
+
# Remote server (via proxy)
|
|
336
349
|
atlassian:
|
|
337
|
-
type:
|
|
338
|
-
|
|
350
|
+
type: local
|
|
351
|
+
command: npx
|
|
352
|
+
args: ["-y", "mcp-remote", "https://mcp.atlassian.com/v1/sse"]
|
|
353
|
+
oauth:
|
|
354
|
+
scope: tools:read
|
|
339
355
|
```
|
|
340
356
|
|
|
341
357
|
#### Using MCP in Steps
|
|
@@ -376,9 +392,9 @@ In these examples, the agent will have access to all tools provided by the MCP s
|
|
|
376
392
|
| `logs <run_id>` | View logs and step status for a specific run |
|
|
377
393
|
| `graph <workflow>` | Generate a Mermaid diagram of the workflow |
|
|
378
394
|
| `config` | Show current configuration and providers |
|
|
379
|
-
| `auth status` | Show authentication status |
|
|
380
|
-
| `auth login` | Login to an authentication provider (
|
|
381
|
-
| `auth logout` | Logout and clear authentication tokens |
|
|
395
|
+
| `auth status [provider]` | Show authentication status |
|
|
396
|
+
| `auth login [provider]` | Login to an authentication provider (github, openai, anthropic) |
|
|
397
|
+
| `auth logout [provider]` | Logout and clear authentication tokens |
|
|
382
398
|
| `ui` | Open the interactive TUI dashboard |
|
|
383
399
|
| `mcp` | Start the Keystone MCP server |
|
|
384
400
|
| `completion [shell]` | Generate shell completion script (zsh, bash) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keystone-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "A local-first, declarative, agentic workflow orchestrator built on Bun",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -13,7 +13,13 @@
|
|
|
13
13
|
"lint:fix": "biome check --write .",
|
|
14
14
|
"format": "biome format --write ."
|
|
15
15
|
},
|
|
16
|
-
"keywords": [
|
|
16
|
+
"keywords": [
|
|
17
|
+
"workflow",
|
|
18
|
+
"orchestrator",
|
|
19
|
+
"agentic",
|
|
20
|
+
"automation",
|
|
21
|
+
"bun"
|
|
22
|
+
],
|
|
17
23
|
"author": "Mark Hingston",
|
|
18
24
|
"license": "MIT",
|
|
19
25
|
"repository": {
|
|
@@ -21,7 +27,12 @@
|
|
|
21
27
|
"url": "https://github.com/mhingston/keystone-cli.git"
|
|
22
28
|
},
|
|
23
29
|
"homepage": "https://github.com/mhingston/keystone-cli#readme",
|
|
24
|
-
"files": [
|
|
30
|
+
"files": [
|
|
31
|
+
"src",
|
|
32
|
+
"README.md",
|
|
33
|
+
"LICENSE",
|
|
34
|
+
"logo.png"
|
|
35
|
+
],
|
|
25
36
|
"dependencies": {
|
|
26
37
|
"@jsep-plugin/arrow": "^1.0.6",
|
|
27
38
|
"@jsep-plugin/object": "^1.2.2",
|
package/src/cli.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
3
|
-
import { join } from 'node:path';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
4
|
import { Command } from 'commander';
|
|
5
5
|
|
|
6
6
|
import exploreAgent from './templates/agents/explore.md' with { type: 'text' };
|
|
@@ -265,7 +265,7 @@ program
|
|
|
265
265
|
|
|
266
266
|
// Import WorkflowRunner dynamically
|
|
267
267
|
const { WorkflowRunner } = await import('./runner/workflow-runner.ts');
|
|
268
|
-
const runner = new WorkflowRunner(workflow, { inputs });
|
|
268
|
+
const runner = new WorkflowRunner(workflow, { inputs, workflowDir: dirname(resolvedPath) });
|
|
269
269
|
|
|
270
270
|
const outputs = await runner.run();
|
|
271
271
|
|
|
@@ -273,6 +273,7 @@ program
|
|
|
273
273
|
console.log('Outputs:');
|
|
274
274
|
console.log(JSON.stringify(runner.redact(outputs), null, 2));
|
|
275
275
|
}
|
|
276
|
+
process.exit(0);
|
|
276
277
|
} catch (error) {
|
|
277
278
|
console.error(
|
|
278
279
|
'✗ Failed to execute workflow:',
|
|
@@ -339,7 +340,10 @@ program
|
|
|
339
340
|
|
|
340
341
|
// Import WorkflowRunner dynamically
|
|
341
342
|
const { WorkflowRunner } = await import('./runner/workflow-runner.ts');
|
|
342
|
-
const runner = new WorkflowRunner(workflow, {
|
|
343
|
+
const runner = new WorkflowRunner(workflow, {
|
|
344
|
+
resumeRunId: runId,
|
|
345
|
+
workflowDir: dirname(workflowPath),
|
|
346
|
+
});
|
|
343
347
|
|
|
344
348
|
const outputs = await runner.run();
|
|
345
349
|
|
|
@@ -347,6 +351,7 @@ program
|
|
|
347
351
|
console.log('Outputs:');
|
|
348
352
|
console.log(JSON.stringify(runner.redact(outputs), null, 2));
|
|
349
353
|
}
|
|
354
|
+
process.exit(0);
|
|
350
355
|
} catch (error) {
|
|
351
356
|
console.error('✗ Failed to resume workflow:', error instanceof Error ? error.message : error);
|
|
352
357
|
process.exit(1);
|
|
@@ -480,9 +485,77 @@ program
|
|
|
480
485
|
});
|
|
481
486
|
|
|
482
487
|
// ===== keystone mcp =====
|
|
483
|
-
program
|
|
484
|
-
|
|
485
|
-
|
|
488
|
+
const mcp = program.command('mcp').description('Model Context Protocol management');
|
|
489
|
+
|
|
490
|
+
mcp
|
|
491
|
+
.command('login')
|
|
492
|
+
.description('Login to an MCP server')
|
|
493
|
+
.argument('<server>', 'Server name (from config)')
|
|
494
|
+
.action(async (serverName) => {
|
|
495
|
+
const { ConfigLoader } = await import('./utils/config-loader.ts');
|
|
496
|
+
const { AuthManager } = await import('./utils/auth-manager.ts');
|
|
497
|
+
|
|
498
|
+
const config = ConfigLoader.load();
|
|
499
|
+
const server = config.mcp_servers[serverName];
|
|
500
|
+
|
|
501
|
+
if (!server || !server.oauth) {
|
|
502
|
+
console.error(`✗ MCP server '${serverName}' is not configured with OAuth.`);
|
|
503
|
+
process.exit(1);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
let url = server.url;
|
|
507
|
+
|
|
508
|
+
// If it's a local server using mcp-remote, try to find the URL in args
|
|
509
|
+
if (!url && server.type === 'local' && server.args) {
|
|
510
|
+
url = server.args.find((arg) => arg.startsWith('http'));
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (!url) {
|
|
514
|
+
console.error(
|
|
515
|
+
`✗ MCP server '${serverName}' does not have a URL configured for authentication.`
|
|
516
|
+
);
|
|
517
|
+
console.log(' Please add a "url" property to your server configuration.');
|
|
518
|
+
process.exit(1);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
console.log(`\n🔐 Authenticating with MCP server: ${serverName}`);
|
|
522
|
+
console.log(` URL: ${url}\n`);
|
|
523
|
+
|
|
524
|
+
// For now, we'll support a manual token entry until we have a full browser redirect flow
|
|
525
|
+
// Most MCP OAuth servers provide a way to get a token via a URL
|
|
526
|
+
const authUrl = url.replace('/sse', '/authorize') || url;
|
|
527
|
+
console.log('1. Visit the following URL to authorize:');
|
|
528
|
+
console.log(` ${authUrl}`);
|
|
529
|
+
console.log(
|
|
530
|
+
'\n Note: If you encounter errors, ensure the server is correctly configured and accessible.'
|
|
531
|
+
);
|
|
532
|
+
console.log(' You can still manually provide an OAuth token below if you have one.');
|
|
533
|
+
console.log('\n2. Paste the access token below:\n');
|
|
534
|
+
|
|
535
|
+
const prompt = 'Access Token: ';
|
|
536
|
+
process.stdout.write(prompt);
|
|
537
|
+
|
|
538
|
+
let token = '';
|
|
539
|
+
for await (const line of console) {
|
|
540
|
+
token = line.trim();
|
|
541
|
+
break;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (token) {
|
|
545
|
+
const auth = AuthManager.load();
|
|
546
|
+
const mcp_tokens = auth.mcp_tokens || {};
|
|
547
|
+
mcp_tokens[serverName] = { access_token: token };
|
|
548
|
+
AuthManager.save({ mcp_tokens });
|
|
549
|
+
console.log(`\n✓ Successfully saved token for MCP server: ${serverName}`);
|
|
550
|
+
} else {
|
|
551
|
+
console.error('✗ No token provided.');
|
|
552
|
+
process.exit(1);
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
mcp
|
|
557
|
+
.command('start')
|
|
558
|
+
.description('Start the Keystone MCP server (to use Keystone as a tool)')
|
|
486
559
|
.action(async () => {
|
|
487
560
|
const { MCPServer } = await import('./runner/mcp-server.ts');
|
|
488
561
|
|
|
@@ -551,18 +624,35 @@ auth
|
|
|
551
624
|
let token = options.token;
|
|
552
625
|
|
|
553
626
|
if (!token) {
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
627
|
+
try {
|
|
628
|
+
const deviceLogin = await AuthManager.initGitHubDeviceLogin();
|
|
629
|
+
|
|
630
|
+
console.log('\nTo login with GitHub:');
|
|
631
|
+
console.log(`1. Visit: ${deviceLogin.verification_uri}`);
|
|
632
|
+
console.log(`2. Enter code: ${deviceLogin.user_code}\n`);
|
|
633
|
+
|
|
634
|
+
console.log('Waiting for authorization...');
|
|
635
|
+
token = await AuthManager.pollGitHubDeviceLogin(deviceLogin.device_code);
|
|
636
|
+
} catch (error) {
|
|
637
|
+
console.error(
|
|
638
|
+
'\n✗ Failed to login with GitHub device flow:',
|
|
639
|
+
error instanceof Error ? error.message : error
|
|
640
|
+
);
|
|
641
|
+
console.log('\nFalling back to manual token entry...');
|
|
642
|
+
|
|
643
|
+
console.log('\nTo login with GitHub manually:');
|
|
644
|
+
console.log(
|
|
645
|
+
'1. Generate a Personal Access Token (Classic) with "copilot" scope (or full repo access).'
|
|
646
|
+
);
|
|
647
|
+
console.log(' https://github.com/settings/tokens/new');
|
|
648
|
+
console.log('2. Paste the token below:\n');
|
|
649
|
+
|
|
650
|
+
const prompt = 'Token: ';
|
|
651
|
+
process.stdout.write(prompt);
|
|
652
|
+
for await (const line of console) {
|
|
653
|
+
token = line.trim();
|
|
654
|
+
break;
|
|
655
|
+
}
|
|
566
656
|
}
|
|
567
657
|
}
|
|
568
658
|
|
|
@@ -59,6 +59,10 @@ describe('ExpressionEvaluator', () => {
|
|
|
59
59
|
expect(ExpressionEvaluator.evaluate('${{ false && 1 }}', context)).toBe(false);
|
|
60
60
|
expect(ExpressionEvaluator.evaluate('${{ true || 1 }}', context)).toBe(true);
|
|
61
61
|
expect(ExpressionEvaluator.evaluate('${{ false || 1 }}', context)).toBe(1);
|
|
62
|
+
// Explicit short-circuit tests
|
|
63
|
+
expect(ExpressionEvaluator.evaluate('${{ false && undefined_var }}', context)).toBe(false);
|
|
64
|
+
expect(ExpressionEvaluator.evaluate('${{ true || undefined_var }}', context)).toBe(true);
|
|
65
|
+
expect(ExpressionEvaluator.evaluate('${{ true && 2 }}', context)).toBe(2);
|
|
62
66
|
});
|
|
63
67
|
|
|
64
68
|
test('should support comparison operators', () => {
|
|
@@ -83,7 +83,15 @@ export class ExpressionEvaluator {
|
|
|
83
83
|
return '';
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
if (typeof result === 'object') {
|
|
86
|
+
if (typeof result === 'object' && result !== null) {
|
|
87
|
+
// Special handling for shell command results to avoid [object Object] or JSON in commands
|
|
88
|
+
if (
|
|
89
|
+
'stdout' in result &&
|
|
90
|
+
'exitCode' in result &&
|
|
91
|
+
typeof (result as Record<string, unknown>).stdout === 'string'
|
|
92
|
+
) {
|
|
93
|
+
return (result as Record<string, unknown>).stdout.trim();
|
|
94
|
+
}
|
|
87
95
|
return JSON.stringify(result, null, 2);
|
|
88
96
|
}
|
|
89
97
|
|
|
@@ -44,11 +44,18 @@ export function parseAgent(filePath: string): Agent {
|
|
|
44
44
|
return result.data;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
export function resolveAgentPath(agentName: string): string {
|
|
48
|
-
const possiblePaths = [
|
|
47
|
+
export function resolveAgentPath(agentName: string, baseDir?: string): string {
|
|
48
|
+
const possiblePaths: string[] = [];
|
|
49
|
+
|
|
50
|
+
if (baseDir) {
|
|
51
|
+
possiblePaths.push(join(baseDir, 'agents', `${agentName}.md`));
|
|
52
|
+
possiblePaths.push(join(baseDir, '..', 'agents', `${agentName}.md`));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
possiblePaths.push(
|
|
49
56
|
join(process.cwd(), '.keystone', 'workflows', 'agents', `${agentName}.md`),
|
|
50
|
-
join(homedir(), '.keystone', 'workflows', 'agents', `${agentName}.md`)
|
|
51
|
-
|
|
57
|
+
join(homedir(), '.keystone', 'workflows', 'agents', `${agentName}.md`)
|
|
58
|
+
);
|
|
52
59
|
|
|
53
60
|
for (const path of possiblePaths) {
|
|
54
61
|
if (existsSync(path)) {
|
|
@@ -48,11 +48,22 @@ export const ConfigSchema = z.object({
|
|
|
48
48
|
command: z.string(),
|
|
49
49
|
args: z.array(z.string()).optional(),
|
|
50
50
|
env: z.record(z.string()).optional(),
|
|
51
|
+
url: z.string().url().optional(),
|
|
52
|
+
oauth: z
|
|
53
|
+
.object({
|
|
54
|
+
scope: z.string().optional(),
|
|
55
|
+
})
|
|
56
|
+
.optional(),
|
|
51
57
|
}),
|
|
52
58
|
z.object({
|
|
53
59
|
type: z.literal('remote'),
|
|
54
60
|
url: z.string().url(),
|
|
55
61
|
headers: z.record(z.string()).optional(),
|
|
62
|
+
oauth: z
|
|
63
|
+
.object({
|
|
64
|
+
scope: z.string().optional(),
|
|
65
|
+
})
|
|
66
|
+
.optional(),
|
|
56
67
|
}),
|
|
57
68
|
])
|
|
58
69
|
)
|
package/src/parser/schema.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
3
|
import * as yaml from 'js-yaml';
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { ExpressionEvaluator } from '../expression/evaluator.ts';
|
|
@@ -15,6 +15,7 @@ export class WorkflowParser {
|
|
|
15
15
|
const content = readFileSync(path, 'utf-8');
|
|
16
16
|
const raw = yaml.load(content);
|
|
17
17
|
const workflow = WorkflowSchema.parse(raw);
|
|
18
|
+
const workflowDir = dirname(path);
|
|
18
19
|
|
|
19
20
|
// Resolve implicit dependencies from expressions
|
|
20
21
|
WorkflowParser.resolveImplicitDependencies(workflow);
|
|
@@ -23,7 +24,7 @@ export class WorkflowParser {
|
|
|
23
24
|
WorkflowParser.validateDAG(workflow);
|
|
24
25
|
|
|
25
26
|
// Validate agents exist
|
|
26
|
-
WorkflowParser.validateAgents(workflow);
|
|
27
|
+
WorkflowParser.validateAgents(workflow, workflowDir);
|
|
27
28
|
|
|
28
29
|
// Validate finally block
|
|
29
30
|
WorkflowParser.validateFinally(workflow);
|
|
@@ -121,12 +122,12 @@ export class WorkflowParser {
|
|
|
121
122
|
/**
|
|
122
123
|
* Validate that all agents referenced in LLM steps exist
|
|
123
124
|
*/
|
|
124
|
-
private static validateAgents(workflow: Workflow): void {
|
|
125
|
+
private static validateAgents(workflow: Workflow, baseDir?: string): void {
|
|
125
126
|
const allSteps = [...workflow.steps, ...(workflow.finally || [])];
|
|
126
127
|
for (const step of allSteps) {
|
|
127
128
|
if (step.type === 'llm') {
|
|
128
129
|
try {
|
|
129
|
-
resolveAgentPath(step.agent);
|
|
130
|
+
resolveAgentPath(step.agent, baseDir);
|
|
130
131
|
} catch (error) {
|
|
131
132
|
throw new Error(`Agent "${step.agent}" referenced in step "${step.id}" not found.`);
|
|
132
133
|
}
|