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 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. For other providers, API keys should be stored in a `.env` file in your project root:
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 (SSE)
348
+ # Remote server (via proxy)
336
349
  atlassian:
337
- type: remote
338
- url: https://mcp.atlassian.com/v1/sse
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 (GitHub) |
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.2.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": ["workflow", "orchestrator", "agentic", "automation", "bun"],
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": ["src", "README.md", "LICENSE", "logo.png"],
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, { resumeRunId: runId });
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
- .command('mcp')
485
- .description('Start the Model Context Protocol server')
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
- console.log('\nTo login with GitHub:');
555
- console.log(
556
- '1. Generate a Personal Access Token (Classic) with "copilot" scope (or full repo access).'
557
- );
558
- console.log(' https://github.com/settings/tokens/new');
559
- console.log('2. Paste the token below:\n');
560
-
561
- const prompt = 'Token: ';
562
- process.stdout.write(prompt);
563
- for await (const line of console) {
564
- token = line.trim();
565
- break;
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
  )
@@ -3,7 +3,7 @@ import { z } from 'zod';
3
3
  // ===== Input/Output Schema =====
4
4
 
5
5
  const InputSchema = z.object({
6
- type: z.string(),
6
+ type: z.enum(['string', 'number', 'boolean', 'array', 'object']),
7
7
  default: z.any().optional(),
8
8
  description: z.string().optional(),
9
9
  });
@@ -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
  }