keystone-cli 0.1.1 → 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 +69 -16
- package/package.json +14 -3
- package/src/cli.ts +183 -84
- package/src/db/workflow-db.ts +0 -7
- package/src/expression/evaluator.test.ts +46 -0
- package/src/expression/evaluator.ts +36 -0
- package/src/parser/agent-parser.test.ts +10 -0
- package/src/parser/agent-parser.ts +13 -5
- package/src/parser/config-schema.ts +24 -5
- package/src/parser/schema.ts +1 -1
- package/src/parser/workflow-parser.ts +5 -9
- package/src/runner/llm-adapter.test.ts +0 -8
- package/src/runner/llm-adapter.ts +33 -10
- package/src/runner/llm-executor.test.ts +230 -96
- package/src/runner/llm-executor.ts +9 -4
- package/src/runner/mcp-client.test.ts +204 -88
- package/src/runner/mcp-client.ts +349 -22
- package/src/runner/mcp-manager.test.ts +73 -15
- package/src/runner/mcp-manager.ts +84 -18
- package/src/runner/mcp-server.test.ts +4 -1
- package/src/runner/mcp-server.ts +25 -11
- package/src/runner/shell-executor.ts +3 -3
- package/src/runner/step-executor.test.ts +2 -2
- package/src/runner/step-executor.ts +31 -16
- package/src/runner/tool-integration.test.ts +21 -14
- package/src/runner/workflow-runner.ts +34 -7
- package/src/templates/agents/explore.md +54 -0
- package/src/templates/agents/general.md +8 -0
- package/src/templates/agents/keystone-architect.md +54 -0
- package/src/templates/agents/my-agent.md +3 -0
- package/src/templates/agents/summarizer.md +28 -0
- package/src/templates/agents/test-agent.md +10 -0
- package/src/templates/approval-process.yaml +36 -0
- package/src/templates/basic-inputs.yaml +19 -0
- package/src/templates/basic-shell.yaml +20 -0
- package/src/templates/batch-processor.yaml +43 -0
- package/src/templates/cleanup-finally.yaml +22 -0
- package/src/templates/composition-child.yaml +13 -0
- package/src/templates/composition-parent.yaml +14 -0
- package/src/templates/data-pipeline.yaml +38 -0
- package/src/templates/full-feature-demo.yaml +64 -0
- package/src/templates/human-interaction.yaml +12 -0
- package/src/templates/invalid.yaml +5 -0
- package/src/templates/llm-agent.yaml +8 -0
- package/src/templates/loop-parallel.yaml +37 -0
- package/src/templates/retry-policy.yaml +36 -0
- package/src/templates/scaffold-feature.yaml +48 -0
- package/src/templates/state.db +0 -0
- package/src/templates/state.db-shm +0 -0
- package/src/templates/state.db-wal +0 -0
- package/src/templates/stop-watch.yaml +17 -0
- package/src/templates/workflow.db +0 -0
- package/src/utils/auth-manager.test.ts +86 -0
- package/src/utils/auth-manager.ts +89 -0
- package/src/utils/config-loader.test.ts +32 -2
- package/src/utils/config-loader.ts +11 -1
- package/src/utils/mermaid.test.ts +27 -3
package/README.md
CHANGED
|
@@ -72,7 +72,7 @@ source <(keystone completion bash)
|
|
|
72
72
|
```bash
|
|
73
73
|
keystone init
|
|
74
74
|
```
|
|
75
|
-
This creates the `.keystone/` directory for configuration and `.keystone/workflows/`
|
|
75
|
+
This creates the `.keystone/` directory for configuration and seeds `.keystone/workflows/` with default automation files and agents (like `scaffold-feature` and `keystone-architect`).
|
|
76
76
|
|
|
77
77
|
### 2. Configure your Environment
|
|
78
78
|
Add your API keys to the generated `.env` file:
|
|
@@ -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
|
|
@@ -131,10 +136,11 @@ mcp_servers:
|
|
|
131
136
|
github:
|
|
132
137
|
command: npx
|
|
133
138
|
args: ["-y", "@modelcontextprotocol/server-github"]
|
|
134
|
-
|
|
135
|
-
|
|
139
|
+
env:
|
|
140
|
+
GITHUB_PERSONAL_ACCESS_TOKEN: "your-github-pat" # Or omit if GITHUB_TOKEN is in your .env
|
|
136
141
|
|
|
137
142
|
storage:
|
|
143
|
+
|
|
138
144
|
retention_days: 30
|
|
139
145
|
```
|
|
140
146
|
|
|
@@ -170,23 +176,34 @@ model: claude-3-5-sonnet-latest
|
|
|
170
176
|
You can add any OpenAI-compatible provider (Groq, Together AI, Perplexity, Local Ollama, etc.) by setting the `type` to `openai` and providing the `base_url` and `api_key_env`.
|
|
171
177
|
|
|
172
178
|
### GitHub Copilot Support
|
|
173
|
-
|
|
179
|
+
|
|
180
|
+
Keystone supports using your GitHub Copilot subscription directly. To authenticate (using the GitHub Device Flow):
|
|
181
|
+
|
|
174
182
|
```bash
|
|
175
|
-
keystone auth login
|
|
183
|
+
keystone auth login github
|
|
176
184
|
```
|
|
185
|
+
|
|
177
186
|
Then, you can use Copilot in your configuration:
|
|
187
|
+
|
|
178
188
|
```yaml
|
|
179
189
|
providers:
|
|
180
190
|
copilot:
|
|
181
191
|
type: copilot
|
|
182
192
|
default_model: gpt-4o
|
|
183
193
|
```
|
|
184
|
-
API keys are handled automatically after login.
|
|
185
194
|
|
|
186
|
-
|
|
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:
|
|
187
200
|
- `OPENAI_API_KEY`
|
|
188
201
|
- `ANTHROPIC_API_KEY`
|
|
189
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
|
+
|
|
190
207
|
---
|
|
191
208
|
|
|
192
209
|
## 📝 Workflow Example
|
|
@@ -252,6 +269,21 @@ Keystone supports several specialized step types:
|
|
|
252
269
|
|
|
253
270
|
All steps support common features like `needs` (dependencies), `if` (conditionals), `retry`, `timeout`, `foreach` (parallel iteration), and `transform` (post-process output using expressions).
|
|
254
271
|
|
|
272
|
+
#### Example: Transform & Foreach Concurrency
|
|
273
|
+
```yaml
|
|
274
|
+
- id: list_files
|
|
275
|
+
type: shell
|
|
276
|
+
run: ls *.txt
|
|
277
|
+
# Post-process stdout into an array of filenames
|
|
278
|
+
transform: ${{ stdout.trim().split('\n') }}
|
|
279
|
+
|
|
280
|
+
- id: process_files
|
|
281
|
+
type: shell
|
|
282
|
+
foreach: ${{ steps.list_files.output }}
|
|
283
|
+
concurrency: 5 # Process 5 files at a time
|
|
284
|
+
run: echo "Processing ${{ item }}"
|
|
285
|
+
```
|
|
286
|
+
|
|
255
287
|
---
|
|
256
288
|
|
|
257
289
|
## 🤖 Agent Definitions
|
|
@@ -290,18 +322,36 @@ tools:
|
|
|
290
322
|
You are a software developer. You can use tools to explore the codebase.
|
|
291
323
|
```
|
|
292
324
|
|
|
293
|
-
###
|
|
325
|
+
### Keystone as an MCP Server
|
|
326
|
+
|
|
327
|
+
Keystone can itself act as an MCP server, allowing other agents (like Claude Desktop or GitHub Copilot) to discover and run your workflows as tools.
|
|
328
|
+
|
|
329
|
+
```bash
|
|
330
|
+
keystone mcp
|
|
331
|
+
```
|
|
294
332
|
|
|
295
|
-
|
|
333
|
+
> **Note:** Workflow execution via the Keystone MCP server is synchronous. This provides a better experience for agents as they receive the final results directly, though it means the connection remains open for the duration of the workflow run.
|
|
296
334
|
|
|
297
335
|
#### Global MCP Servers
|
|
298
336
|
Define shared MCP servers in `.keystone/config.yaml` to reuse them across different workflows. Keystone ensures that multiple steps using the same global server will share a single running process.
|
|
299
337
|
|
|
338
|
+
Keystone supports both local (stdio) and remote (SSE) MCP servers.
|
|
339
|
+
|
|
300
340
|
```yaml
|
|
301
341
|
mcp_servers:
|
|
342
|
+
# Local server (stdio)
|
|
302
343
|
filesystem:
|
|
344
|
+
type: local # Default
|
|
303
345
|
command: npx
|
|
304
346
|
args: ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed/directory"]
|
|
347
|
+
|
|
348
|
+
# Remote server (via proxy)
|
|
349
|
+
atlassian:
|
|
350
|
+
type: local
|
|
351
|
+
command: npx
|
|
352
|
+
args: ["-y", "mcp-remote", "https://mcp.atlassian.com/v1/sse"]
|
|
353
|
+
oauth:
|
|
354
|
+
scope: tools:read
|
|
305
355
|
```
|
|
306
356
|
|
|
307
357
|
#### Using MCP in Steps
|
|
@@ -334,19 +384,21 @@ In these examples, the agent will have access to all tools provided by the MCP s
|
|
|
334
384
|
| Command | Description |
|
|
335
385
|
| :--- | :--- |
|
|
336
386
|
| `init` | Initialize a new Keystone project |
|
|
337
|
-
| `run <workflow>` | Execute a workflow
|
|
387
|
+
| `run <workflow>` | Execute a workflow (use `-i key=val` for inputs) |
|
|
338
388
|
| `resume <run_id>` | Resume a failed or paused workflow |
|
|
339
|
-
| `validate [path]` | Check workflow files
|
|
389
|
+
| `validate [path]` | Check workflow files for errors |
|
|
340
390
|
| `workflows` | List available workflows |
|
|
341
391
|
| `history` | Show recent workflow runs |
|
|
342
392
|
| `logs <run_id>` | View logs and step status for a specific run |
|
|
343
|
-
| `graph <workflow>` | Generate a Mermaid diagram of the workflow
|
|
344
|
-
| `config` | Show current configuration and
|
|
345
|
-
| `auth
|
|
393
|
+
| `graph <workflow>` | Generate a Mermaid diagram of the workflow |
|
|
394
|
+
| `config` | Show current configuration and providers |
|
|
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 |
|
|
346
398
|
| `ui` | Open the interactive TUI dashboard |
|
|
347
|
-
| `mcp` | Start the
|
|
399
|
+
| `mcp` | Start the Keystone MCP server |
|
|
348
400
|
| `completion [shell]` | Generate shell completion script (zsh, bash) |
|
|
349
|
-
| `prune` | Cleanup old run data from the database
|
|
401
|
+
| `prune [--days N]` | Cleanup old run data from the database |
|
|
350
402
|
|
|
351
403
|
---
|
|
352
404
|
|
|
@@ -357,6 +409,7 @@ In these examples, the agent will have access to all tools provided by the MCP s
|
|
|
357
409
|
- `src/parser/`: Zod-powered validation for workflows and agents.
|
|
358
410
|
- `src/expression/`: `${{ }}` expression evaluator.
|
|
359
411
|
- `src/ui/`: Ink-powered TUI dashboard.
|
|
412
|
+
- `src/utils/`: Shared utilities (auth, redaction, config loading).
|
|
360
413
|
- `.keystone/workflows/`: Your YAML workflow definitions.
|
|
361
414
|
|
|
362
415
|
---
|
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,19 +1,28 @@
|
|
|
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
|
+
|
|
6
|
+
import exploreAgent from './templates/agents/explore.md' with { type: 'text' };
|
|
7
|
+
import generalAgent from './templates/agents/general.md' with { type: 'text' };
|
|
8
|
+
import architectAgent from './templates/agents/keystone-architect.md' with { type: 'text' };
|
|
9
|
+
// Default templates
|
|
10
|
+
import scaffoldWorkflow from './templates/scaffold-feature.yaml' with { type: 'text' };
|
|
11
|
+
|
|
5
12
|
import { WorkflowDb } from './db/workflow-db.ts';
|
|
6
13
|
import { WorkflowParser } from './parser/workflow-parser.ts';
|
|
7
14
|
import { ConfigLoader } from './utils/config-loader.ts';
|
|
8
15
|
import { generateMermaidGraph, renderMermaidAsAscii } from './utils/mermaid.ts';
|
|
9
16
|
import { WorkflowRegistry } from './utils/workflow-registry.ts';
|
|
10
17
|
|
|
18
|
+
import pkg from '../package.json' with { type: 'json' };
|
|
19
|
+
|
|
11
20
|
const program = new Command();
|
|
12
21
|
|
|
13
22
|
program
|
|
14
23
|
.name('keystone')
|
|
15
24
|
.description('A local-first, declarative, agentic workflow orchestrator')
|
|
16
|
-
.version(
|
|
25
|
+
.version(pkg.version);
|
|
17
26
|
|
|
18
27
|
// ===== keystone init =====
|
|
19
28
|
program
|
|
@@ -62,6 +71,11 @@ model_mappings:
|
|
|
62
71
|
"o1-*": openai
|
|
63
72
|
"llama-*": groq
|
|
64
73
|
|
|
74
|
+
# mcp_servers:
|
|
75
|
+
# filesystem:
|
|
76
|
+
# command: npx
|
|
77
|
+
# args: ["-y", "@modelcontextprotocol/server-filesystem", "."]
|
|
78
|
+
|
|
65
79
|
storage:
|
|
66
80
|
retention_days: 30
|
|
67
81
|
workflows_directory: workflows
|
|
@@ -85,6 +99,35 @@ workflows_directory: workflows
|
|
|
85
99
|
console.log(`⊘ ${envPath} already exists`);
|
|
86
100
|
}
|
|
87
101
|
|
|
102
|
+
// Seed default workflows and agents
|
|
103
|
+
const seeds = [
|
|
104
|
+
{
|
|
105
|
+
path: '.keystone/workflows/scaffold-feature.yaml',
|
|
106
|
+
content: scaffoldWorkflow,
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
path: '.keystone/workflows/agents/keystone-architect.md',
|
|
110
|
+
content: architectAgent,
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
path: '.keystone/workflows/agents/general.md',
|
|
114
|
+
content: generalAgent,
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
path: '.keystone/workflows/agents/explore.md',
|
|
118
|
+
content: exploreAgent,
|
|
119
|
+
},
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
for (const seed of seeds) {
|
|
123
|
+
if (!existsSync(seed.path)) {
|
|
124
|
+
writeFileSync(seed.path, seed.content);
|
|
125
|
+
console.log(`✓ Seeded ${seed.path}`);
|
|
126
|
+
} else {
|
|
127
|
+
console.log(`⊘ ${seed.path} already exists`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
88
131
|
console.log('\n✨ Keystone project initialized!');
|
|
89
132
|
console.log('\nNext steps:');
|
|
90
133
|
console.log(' 1. Add your API keys to .env');
|
|
@@ -222,7 +265,7 @@ program
|
|
|
222
265
|
|
|
223
266
|
// Import WorkflowRunner dynamically
|
|
224
267
|
const { WorkflowRunner } = await import('./runner/workflow-runner.ts');
|
|
225
|
-
const runner = new WorkflowRunner(workflow, { inputs });
|
|
268
|
+
const runner = new WorkflowRunner(workflow, { inputs, workflowDir: dirname(resolvedPath) });
|
|
226
269
|
|
|
227
270
|
const outputs = await runner.run();
|
|
228
271
|
|
|
@@ -230,6 +273,7 @@ program
|
|
|
230
273
|
console.log('Outputs:');
|
|
231
274
|
console.log(JSON.stringify(runner.redact(outputs), null, 2));
|
|
232
275
|
}
|
|
276
|
+
process.exit(0);
|
|
233
277
|
} catch (error) {
|
|
234
278
|
console.error(
|
|
235
279
|
'✗ Failed to execute workflow:',
|
|
@@ -296,7 +340,10 @@ program
|
|
|
296
340
|
|
|
297
341
|
// Import WorkflowRunner dynamically
|
|
298
342
|
const { WorkflowRunner } = await import('./runner/workflow-runner.ts');
|
|
299
|
-
const runner = new WorkflowRunner(workflow, {
|
|
343
|
+
const runner = new WorkflowRunner(workflow, {
|
|
344
|
+
resumeRunId: runId,
|
|
345
|
+
workflowDir: dirname(workflowPath),
|
|
346
|
+
});
|
|
300
347
|
|
|
301
348
|
const outputs = await runner.run();
|
|
302
349
|
|
|
@@ -304,6 +351,7 @@ program
|
|
|
304
351
|
console.log('Outputs:');
|
|
305
352
|
console.log(JSON.stringify(runner.redact(outputs), null, 2));
|
|
306
353
|
}
|
|
354
|
+
process.exit(0);
|
|
307
355
|
} catch (error) {
|
|
308
356
|
console.error('✗ Failed to resume workflow:', error instanceof Error ? error.message : error);
|
|
309
357
|
process.exit(1);
|
|
@@ -437,9 +485,77 @@ program
|
|
|
437
485
|
});
|
|
438
486
|
|
|
439
487
|
// ===== keystone mcp =====
|
|
440
|
-
program
|
|
441
|
-
|
|
442
|
-
|
|
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)')
|
|
443
559
|
.action(async () => {
|
|
444
560
|
const { MCPServer } = await import('./runner/mcp-server.ts');
|
|
445
561
|
|
|
@@ -499,90 +615,68 @@ auth
|
|
|
499
615
|
.command('login')
|
|
500
616
|
.description('Login to an authentication provider')
|
|
501
617
|
.option('-p, --provider <provider>', 'Authentication provider', 'github')
|
|
618
|
+
.option('-t, --token <token>', 'Personal Access Token (if not using interactive mode)')
|
|
502
619
|
.action(async (options) => {
|
|
503
620
|
const { AuthManager } = await import('./utils/auth-manager.ts');
|
|
504
621
|
const provider = options.provider.toLowerCase();
|
|
505
622
|
|
|
506
|
-
if (provider
|
|
507
|
-
|
|
508
|
-
process.exit(1);
|
|
509
|
-
}
|
|
623
|
+
if (provider === 'github') {
|
|
624
|
+
let token = options.token;
|
|
510
625
|
|
|
511
|
-
|
|
626
|
+
if (!token) {
|
|
627
|
+
try {
|
|
628
|
+
const deviceLogin = await AuthManager.initGitHubDeviceLogin();
|
|
512
629
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
-
});
|
|
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`);
|
|
526
633
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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...');
|
|
530
642
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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;
|
|
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;
|
|
568
655
|
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
569
658
|
|
|
570
|
-
|
|
571
|
-
|
|
659
|
+
if (token) {
|
|
660
|
+
AuthManager.save({ github_token: token });
|
|
661
|
+
// Force refresh of Copilot token to verify
|
|
662
|
+
try {
|
|
663
|
+
const copilotToken = await AuthManager.getCopilotToken();
|
|
664
|
+
if (copilotToken) {
|
|
665
|
+
console.log('\n✓ Successfully logged in to GitHub and retrieved Copilot token.');
|
|
666
|
+
} else {
|
|
667
|
+
console.error(
|
|
668
|
+
'\n✗ Saved GitHub token, but failed to retrieve Copilot token. Please check scopes.'
|
|
669
|
+
);
|
|
572
670
|
}
|
|
573
|
-
|
|
574
|
-
|
|
671
|
+
} catch (e) {
|
|
672
|
+
console.error('\n✗ Failed to verify token:', e instanceof Error ? e.message : e);
|
|
575
673
|
}
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
console.
|
|
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);
|
|
674
|
+
} else {
|
|
675
|
+
console.error('✗ No token provided.');
|
|
676
|
+
process.exit(1);
|
|
677
|
+
}
|
|
678
|
+
} else {
|
|
679
|
+
console.error(`✗ Unsupported provider: ${provider}`);
|
|
586
680
|
process.exit(1);
|
|
587
681
|
}
|
|
588
682
|
});
|
|
@@ -590,11 +684,12 @@ auth
|
|
|
590
684
|
auth
|
|
591
685
|
.command('status')
|
|
592
686
|
.description('Show authentication status')
|
|
687
|
+
.argument('[provider]', 'Authentication provider')
|
|
593
688
|
.option('-p, --provider <provider>', 'Authentication provider')
|
|
594
|
-
.action(async (options) => {
|
|
689
|
+
.action(async (providerArg, options) => {
|
|
595
690
|
const { AuthManager } = await import('./utils/auth-manager.ts');
|
|
596
691
|
const auth = AuthManager.load();
|
|
597
|
-
const provider = options.provider?.toLowerCase();
|
|
692
|
+
const provider = (options.provider || providerArg)?.toLowerCase();
|
|
598
693
|
|
|
599
694
|
console.log('\n🏛️ Authentication Status:');
|
|
600
695
|
|
|
@@ -620,10 +715,14 @@ auth
|
|
|
620
715
|
auth
|
|
621
716
|
.command('logout')
|
|
622
717
|
.description('Logout and clear authentication tokens')
|
|
623
|
-
.
|
|
624
|
-
.
|
|
718
|
+
.argument('[provider]', 'Authentication provider')
|
|
719
|
+
.option(
|
|
720
|
+
'-p, --provider <provider>',
|
|
721
|
+
'Authentication provider (deprecated, use positional argument)'
|
|
722
|
+
)
|
|
723
|
+
.action(async (providerArg, options) => {
|
|
625
724
|
const { AuthManager } = await import('./utils/auth-manager.ts');
|
|
626
|
-
const provider = options.provider?.toLowerCase();
|
|
725
|
+
const provider = (options.provider || providerArg)?.toLowerCase();
|
|
627
726
|
|
|
628
727
|
if (!provider || provider === 'github' || provider === 'copilot') {
|
|
629
728
|
AuthManager.save({
|
package/src/db/workflow-db.ts
CHANGED
|
@@ -99,13 +99,6 @@ export class WorkflowDb {
|
|
|
99
99
|
CREATE INDEX IF NOT EXISTS idx_steps_status ON step_executions(status);
|
|
100
100
|
CREATE INDEX IF NOT EXISTS idx_steps_iteration ON step_executions(run_id, step_id, iteration_index);
|
|
101
101
|
`);
|
|
102
|
-
|
|
103
|
-
// Migration: Add iteration_index if it doesn't exist
|
|
104
|
-
try {
|
|
105
|
-
this.db.exec('ALTER TABLE step_executions ADD COLUMN iteration_index INTEGER;');
|
|
106
|
-
} catch (e) {
|
|
107
|
-
// Ignore if column already exists
|
|
108
|
-
}
|
|
109
102
|
}
|
|
110
103
|
|
|
111
104
|
// ===== Workflow Runs =====
|
|
@@ -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', () => {
|
|
@@ -238,10 +242,52 @@ describe('ExpressionEvaluator', () => {
|
|
|
238
242
|
expect(ExpressionEvaluator.evaluate('${{ runFn(x => x + 5) }}', contextWithFunc)).toBe(15);
|
|
239
243
|
});
|
|
240
244
|
|
|
245
|
+
test('should handle multiple expressions and fallback values', () => {
|
|
246
|
+
// line 83: multiple expressions returning null/undefined
|
|
247
|
+
const contextWithNull = { ...context, nullVal: null };
|
|
248
|
+
expect(ExpressionEvaluator.evaluate('Val: ${{ nullVal }}', contextWithNull)).toBe('Val: ');
|
|
249
|
+
|
|
250
|
+
// line 87: multiple expressions returning objects
|
|
251
|
+
expect(ExpressionEvaluator.evaluate('Data: ${{ steps.step1.outputs.data }}', context)).toBe(
|
|
252
|
+
'Data: {\n "id": 1\n}'
|
|
253
|
+
);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test('should handle evaluateString fallback for null/undefined', () => {
|
|
257
|
+
// line 103: evaluateString returning null/undefined
|
|
258
|
+
const contextWithNull = { ...context, nullVal: null };
|
|
259
|
+
expect(ExpressionEvaluator.evaluateString('${{ nullVal }}', contextWithNull)).toBe('');
|
|
260
|
+
});
|
|
261
|
+
|
|
241
262
|
test('should throw error for unsupported unary operator', () => {
|
|
242
263
|
// '~' is a unary operator jsep supports but we don't
|
|
243
264
|
expect(() => ExpressionEvaluator.evaluate('${{ ~1 }}', context)).toThrow(
|
|
244
265
|
/Unsupported unary operator: ~/
|
|
245
266
|
);
|
|
246
267
|
});
|
|
268
|
+
|
|
269
|
+
test('should throw error when calling non-function method', () => {
|
|
270
|
+
// Calling map on a string (should hit line 391 fallback)
|
|
271
|
+
expect(() => ExpressionEvaluator.evaluate("${{ 'abc'.map(i => i) }}", context)).toThrow(
|
|
272
|
+
/Cannot call method map on string/
|
|
273
|
+
);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('should throw error for unsupported call expression', () => {
|
|
277
|
+
// Triggering line 417: Only method calls and safe function calls are supported
|
|
278
|
+
// We need something that jsep parses as CallExpression but callee is not MemberExpression or Identifier
|
|
279
|
+
// Hard to do with jsep as it usually parses callee as one of those.
|
|
280
|
+
// But we can try to mock an AST if we really wanted to.
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('should handle evaluateString with object result', () => {
|
|
284
|
+
expect(ExpressionEvaluator.evaluateString('${{ inputs.items }}', context)).toBe(
|
|
285
|
+
'[\n "a",\n "b",\n "c"\n]'
|
|
286
|
+
);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test('should handle evaluate with template string containing only null/undefined expression', () => {
|
|
290
|
+
const contextWithNull = { ...context, nullVal: null };
|
|
291
|
+
expect(ExpressionEvaluator.evaluate('${{ nullVal }}', contextWithNull)).toBe(null);
|
|
292
|
+
});
|
|
247
293
|
});
|