keystone-cli 0.1.1 → 0.2.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 +52 -15
- package/package.json +1 -1
- package/src/cli.ts +90 -81
- package/src/db/workflow-db.ts +0 -7
- package/src/expression/evaluator.test.ts +42 -0
- package/src/expression/evaluator.ts +28 -0
- package/src/parser/agent-parser.test.ts +10 -0
- package/src/parser/agent-parser.ts +2 -1
- package/src/parser/config-schema.ts +13 -5
- package/src/parser/workflow-parser.ts +0 -5
- package/src/runner/llm-adapter.test.ts +0 -8
- package/src/runner/llm-adapter.ts +33 -10
- package/src/runner/llm-executor.test.ts +59 -18
- package/src/runner/llm-executor.ts +1 -1
- package/src/runner/mcp-client.test.ts +166 -88
- package/src/runner/mcp-client.ts +156 -22
- package/src/runner/mcp-manager.test.ts +73 -15
- package/src/runner/mcp-manager.ts +44 -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.ts +10 -9
- package/src/runner/tool-integration.test.ts +21 -14
- package/src/runner/workflow-runner.ts +25 -5
- 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/config-loader.test.ts +2 -2
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:
|
|
@@ -131,10 +131,11 @@ mcp_servers:
|
|
|
131
131
|
github:
|
|
132
132
|
command: npx
|
|
133
133
|
args: ["-y", "@modelcontextprotocol/server-github"]
|
|
134
|
-
|
|
135
|
-
|
|
134
|
+
env:
|
|
135
|
+
GITHUB_PERSONAL_ACCESS_TOKEN: "your-github-pat" # Or omit if GITHUB_TOKEN is in your .env
|
|
136
136
|
|
|
137
137
|
storage:
|
|
138
|
+
|
|
138
139
|
retention_days: 30
|
|
139
140
|
```
|
|
140
141
|
|
|
@@ -170,20 +171,23 @@ model: claude-3-5-sonnet-latest
|
|
|
170
171
|
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
172
|
|
|
172
173
|
### GitHub Copilot Support
|
|
173
|
-
|
|
174
|
+
|
|
175
|
+
Keystone supports using your GitHub Copilot subscription directly. To authenticate (using the GitHub Device Flow):
|
|
176
|
+
|
|
174
177
|
```bash
|
|
175
178
|
keystone auth login
|
|
176
179
|
```
|
|
180
|
+
|
|
177
181
|
Then, you can use Copilot in your configuration:
|
|
182
|
+
|
|
178
183
|
```yaml
|
|
179
184
|
providers:
|
|
180
185
|
copilot:
|
|
181
186
|
type: copilot
|
|
182
187
|
default_model: gpt-4o
|
|
183
188
|
```
|
|
184
|
-
API keys are handled automatically after login.
|
|
185
189
|
|
|
186
|
-
API keys should be stored in a `.env` file in your project root:
|
|
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:
|
|
187
191
|
- `OPENAI_API_KEY`
|
|
188
192
|
- `ANTHROPIC_API_KEY`
|
|
189
193
|
|
|
@@ -252,6 +256,21 @@ Keystone supports several specialized step types:
|
|
|
252
256
|
|
|
253
257
|
All steps support common features like `needs` (dependencies), `if` (conditionals), `retry`, `timeout`, `foreach` (parallel iteration), and `transform` (post-process output using expressions).
|
|
254
258
|
|
|
259
|
+
#### Example: Transform & Foreach Concurrency
|
|
260
|
+
```yaml
|
|
261
|
+
- id: list_files
|
|
262
|
+
type: shell
|
|
263
|
+
run: ls *.txt
|
|
264
|
+
# Post-process stdout into an array of filenames
|
|
265
|
+
transform: ${{ stdout.trim().split('\n') }}
|
|
266
|
+
|
|
267
|
+
- id: process_files
|
|
268
|
+
type: shell
|
|
269
|
+
foreach: ${{ steps.list_files.output }}
|
|
270
|
+
concurrency: 5 # Process 5 files at a time
|
|
271
|
+
run: echo "Processing ${{ item }}"
|
|
272
|
+
```
|
|
273
|
+
|
|
255
274
|
---
|
|
256
275
|
|
|
257
276
|
## 🤖 Agent Definitions
|
|
@@ -290,18 +309,33 @@ tools:
|
|
|
290
309
|
You are a software developer. You can use tools to explore the codebase.
|
|
291
310
|
```
|
|
292
311
|
|
|
293
|
-
###
|
|
312
|
+
### Keystone as an MCP Server
|
|
313
|
+
|
|
314
|
+
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.
|
|
294
315
|
|
|
295
|
-
|
|
316
|
+
```bash
|
|
317
|
+
keystone mcp
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
> **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
321
|
|
|
297
322
|
#### Global MCP Servers
|
|
298
323
|
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
324
|
|
|
325
|
+
Keystone supports both local (stdio) and remote (SSE) MCP servers.
|
|
326
|
+
|
|
300
327
|
```yaml
|
|
301
328
|
mcp_servers:
|
|
329
|
+
# Local server (stdio)
|
|
302
330
|
filesystem:
|
|
331
|
+
type: local # Default
|
|
303
332
|
command: npx
|
|
304
333
|
args: ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed/directory"]
|
|
334
|
+
|
|
335
|
+
# Remote server (SSE)
|
|
336
|
+
atlassian:
|
|
337
|
+
type: remote
|
|
338
|
+
url: https://mcp.atlassian.com/v1/sse
|
|
305
339
|
```
|
|
306
340
|
|
|
307
341
|
#### Using MCP in Steps
|
|
@@ -334,19 +368,21 @@ In these examples, the agent will have access to all tools provided by the MCP s
|
|
|
334
368
|
| Command | Description |
|
|
335
369
|
| :--- | :--- |
|
|
336
370
|
| `init` | Initialize a new Keystone project |
|
|
337
|
-
| `run <workflow>` | Execute a workflow
|
|
371
|
+
| `run <workflow>` | Execute a workflow (use `-i key=val` for inputs) |
|
|
338
372
|
| `resume <run_id>` | Resume a failed or paused workflow |
|
|
339
|
-
| `validate [path]` | Check workflow files
|
|
373
|
+
| `validate [path]` | Check workflow files for errors |
|
|
340
374
|
| `workflows` | List available workflows |
|
|
341
375
|
| `history` | Show recent workflow runs |
|
|
342
376
|
| `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
|
|
377
|
+
| `graph <workflow>` | Generate a Mermaid diagram of the workflow |
|
|
378
|
+
| `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 |
|
|
346
382
|
| `ui` | Open the interactive TUI dashboard |
|
|
347
|
-
| `mcp` | Start the
|
|
383
|
+
| `mcp` | Start the Keystone MCP server |
|
|
348
384
|
| `completion [shell]` | Generate shell completion script (zsh, bash) |
|
|
349
|
-
| `prune` | Cleanup old run data from the database
|
|
385
|
+
| `prune [--days N]` | Cleanup old run data from the database |
|
|
350
386
|
|
|
351
387
|
---
|
|
352
388
|
|
|
@@ -357,6 +393,7 @@ In these examples, the agent will have access to all tools provided by the MCP s
|
|
|
357
393
|
- `src/parser/`: Zod-powered validation for workflows and agents.
|
|
358
394
|
- `src/expression/`: `${{ }}` expression evaluator.
|
|
359
395
|
- `src/ui/`: Ink-powered TUI dashboard.
|
|
396
|
+
- `src/utils/`: Shared utilities (auth, redaction, config loading).
|
|
360
397
|
- `.keystone/workflows/`: Your YAML workflow definitions.
|
|
361
398
|
|
|
362
399
|
---
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -2,18 +2,27 @@
|
|
|
2
2
|
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { 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');
|
|
@@ -499,90 +542,51 @@ auth
|
|
|
499
542
|
.command('login')
|
|
500
543
|
.description('Login to an authentication provider')
|
|
501
544
|
.option('-p, --provider <provider>', 'Authentication provider', 'github')
|
|
545
|
+
.option('-t, --token <token>', 'Personal Access Token (if not using interactive mode)')
|
|
502
546
|
.action(async (options) => {
|
|
503
547
|
const { AuthManager } = await import('./utils/auth-manager.ts');
|
|
504
548
|
const provider = options.provider.toLowerCase();
|
|
505
549
|
|
|
506
|
-
if (provider
|
|
507
|
-
|
|
508
|
-
process.exit(1);
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
console.log(`🏛️ ${provider === 'copilot' ? 'GitHub Copilot' : 'GitHub'} Login\n`);
|
|
550
|
+
if (provider === 'github') {
|
|
551
|
+
let token = options.token;
|
|
512
552
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
if (!deviceCodeResponse.ok) {
|
|
528
|
-
throw new Error(`GitHub API error: ${deviceCodeResponse.statusText}`);
|
|
553
|
+
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;
|
|
566
|
+
}
|
|
529
567
|
}
|
|
530
568
|
|
|
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;
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
if (data.error === 'authorization_pending') {
|
|
571
|
-
continue;
|
|
569
|
+
if (token) {
|
|
570
|
+
AuthManager.save({ github_token: token });
|
|
571
|
+
// Force refresh of Copilot token to verify
|
|
572
|
+
try {
|
|
573
|
+
const copilotToken = await AuthManager.getCopilotToken();
|
|
574
|
+
if (copilotToken) {
|
|
575
|
+
console.log('\n✓ Successfully logged in to GitHub and retrieved Copilot token.');
|
|
576
|
+
} else {
|
|
577
|
+
console.error(
|
|
578
|
+
'\n✗ Saved GitHub token, but failed to retrieve Copilot token. Please check scopes.'
|
|
579
|
+
);
|
|
572
580
|
}
|
|
573
|
-
|
|
574
|
-
|
|
581
|
+
} catch (e) {
|
|
582
|
+
console.error('\n✗ Failed to verify token:', e instanceof Error ? e.message : e);
|
|
575
583
|
}
|
|
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);
|
|
584
|
+
} else {
|
|
585
|
+
console.error('✗ No token provided.');
|
|
586
|
+
process.exit(1);
|
|
587
|
+
}
|
|
588
|
+
} else {
|
|
589
|
+
console.error(`✗ Unsupported provider: ${provider}`);
|
|
586
590
|
process.exit(1);
|
|
587
591
|
}
|
|
588
592
|
});
|
|
@@ -590,11 +594,12 @@ auth
|
|
|
590
594
|
auth
|
|
591
595
|
.command('status')
|
|
592
596
|
.description('Show authentication status')
|
|
597
|
+
.argument('[provider]', 'Authentication provider')
|
|
593
598
|
.option('-p, --provider <provider>', 'Authentication provider')
|
|
594
|
-
.action(async (options) => {
|
|
599
|
+
.action(async (providerArg, options) => {
|
|
595
600
|
const { AuthManager } = await import('./utils/auth-manager.ts');
|
|
596
601
|
const auth = AuthManager.load();
|
|
597
|
-
const provider = options.provider?.toLowerCase();
|
|
602
|
+
const provider = (options.provider || providerArg)?.toLowerCase();
|
|
598
603
|
|
|
599
604
|
console.log('\n🏛️ Authentication Status:');
|
|
600
605
|
|
|
@@ -620,10 +625,14 @@ auth
|
|
|
620
625
|
auth
|
|
621
626
|
.command('logout')
|
|
622
627
|
.description('Logout and clear authentication tokens')
|
|
623
|
-
.
|
|
624
|
-
.
|
|
628
|
+
.argument('[provider]', 'Authentication provider')
|
|
629
|
+
.option(
|
|
630
|
+
'-p, --provider <provider>',
|
|
631
|
+
'Authentication provider (deprecated, use positional argument)'
|
|
632
|
+
)
|
|
633
|
+
.action(async (providerArg, options) => {
|
|
625
634
|
const { AuthManager } = await import('./utils/auth-manager.ts');
|
|
626
|
-
const provider = options.provider?.toLowerCase();
|
|
635
|
+
const provider = (options.provider || providerArg)?.toLowerCase();
|
|
627
636
|
|
|
628
637
|
if (!provider || provider === 'github' || provider === 'copilot') {
|
|
629
638
|
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 =====
|
|
@@ -238,10 +238,52 @@ describe('ExpressionEvaluator', () => {
|
|
|
238
238
|
expect(ExpressionEvaluator.evaluate('${{ runFn(x => x + 5) }}', contextWithFunc)).toBe(15);
|
|
239
239
|
});
|
|
240
240
|
|
|
241
|
+
test('should handle multiple expressions and fallback values', () => {
|
|
242
|
+
// line 83: multiple expressions returning null/undefined
|
|
243
|
+
const contextWithNull = { ...context, nullVal: null };
|
|
244
|
+
expect(ExpressionEvaluator.evaluate('Val: ${{ nullVal }}', contextWithNull)).toBe('Val: ');
|
|
245
|
+
|
|
246
|
+
// line 87: multiple expressions returning objects
|
|
247
|
+
expect(ExpressionEvaluator.evaluate('Data: ${{ steps.step1.outputs.data }}', context)).toBe(
|
|
248
|
+
'Data: {\n "id": 1\n}'
|
|
249
|
+
);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test('should handle evaluateString fallback for null/undefined', () => {
|
|
253
|
+
// line 103: evaluateString returning null/undefined
|
|
254
|
+
const contextWithNull = { ...context, nullVal: null };
|
|
255
|
+
expect(ExpressionEvaluator.evaluateString('${{ nullVal }}', contextWithNull)).toBe('');
|
|
256
|
+
});
|
|
257
|
+
|
|
241
258
|
test('should throw error for unsupported unary operator', () => {
|
|
242
259
|
// '~' is a unary operator jsep supports but we don't
|
|
243
260
|
expect(() => ExpressionEvaluator.evaluate('${{ ~1 }}', context)).toThrow(
|
|
244
261
|
/Unsupported unary operator: ~/
|
|
245
262
|
);
|
|
246
263
|
});
|
|
264
|
+
|
|
265
|
+
test('should throw error when calling non-function method', () => {
|
|
266
|
+
// Calling map on a string (should hit line 391 fallback)
|
|
267
|
+
expect(() => ExpressionEvaluator.evaluate("${{ 'abc'.map(i => i) }}", context)).toThrow(
|
|
268
|
+
/Cannot call method map on string/
|
|
269
|
+
);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('should throw error for unsupported call expression', () => {
|
|
273
|
+
// Triggering line 417: Only method calls and safe function calls are supported
|
|
274
|
+
// We need something that jsep parses as CallExpression but callee is not MemberExpression or Identifier
|
|
275
|
+
// Hard to do with jsep as it usually parses callee as one of those.
|
|
276
|
+
// But we can try to mock an AST if we really wanted to.
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('should handle evaluateString with object result', () => {
|
|
280
|
+
expect(ExpressionEvaluator.evaluateString('${{ inputs.items }}', context)).toBe(
|
|
281
|
+
'[\n "a",\n "b",\n "c"\n]'
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test('should handle evaluate with template string containing only null/undefined expression', () => {
|
|
286
|
+
const contextWithNull = { ...context, nullVal: null };
|
|
287
|
+
expect(ExpressionEvaluator.evaluate('${{ nullVal }}', contextWithNull)).toBe(null);
|
|
288
|
+
});
|
|
247
289
|
});
|
|
@@ -78,10 +78,38 @@ export class ExpressionEvaluator {
|
|
|
78
78
|
// Extract the expression content between ${{ and }}
|
|
79
79
|
const expr = match.replace(/^\$\{\{\s*|\s*\}\}$/g, '');
|
|
80
80
|
const result = ExpressionEvaluator.evaluateExpression(expr, context);
|
|
81
|
+
|
|
82
|
+
if (result === null || result === undefined) {
|
|
83
|
+
return '';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (typeof result === 'object') {
|
|
87
|
+
return JSON.stringify(result, null, 2);
|
|
88
|
+
}
|
|
89
|
+
|
|
81
90
|
return String(result);
|
|
82
91
|
});
|
|
83
92
|
}
|
|
84
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Evaluate a string and ensure the result is a string.
|
|
96
|
+
* Objects and arrays are stringified to JSON.
|
|
97
|
+
* null and undefined return an empty string.
|
|
98
|
+
*/
|
|
99
|
+
static evaluateString(template: string, context: ExpressionContext): string {
|
|
100
|
+
const result = ExpressionEvaluator.evaluate(template, context);
|
|
101
|
+
|
|
102
|
+
if (result === null || result === undefined) {
|
|
103
|
+
return '';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (typeof result === 'string') {
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return JSON.stringify(result, null, 2);
|
|
111
|
+
}
|
|
112
|
+
|
|
85
113
|
/**
|
|
86
114
|
* Evaluate a single expression (without the ${{ }} wrapper)
|
|
87
115
|
* This is public to support transform expressions in shell steps
|
|
@@ -63,6 +63,16 @@ tools:
|
|
|
63
63
|
expect(agent.tools[0].execution.id).toBe('tool-tool-without-id');
|
|
64
64
|
});
|
|
65
65
|
|
|
66
|
+
it('should parse single-line frontmatter', () => {
|
|
67
|
+
const agentContent = '---name: single-line---\nPrompt';
|
|
68
|
+
const filePath = join(tempDir, 'single-line.md');
|
|
69
|
+
writeFileSync(filePath, agentContent);
|
|
70
|
+
|
|
71
|
+
const agent = parseAgent(filePath);
|
|
72
|
+
expect(agent.name).toBe('single-line');
|
|
73
|
+
expect(agent.systemPrompt).toBe('Prompt');
|
|
74
|
+
});
|
|
75
|
+
|
|
66
76
|
it('should throw error for missing frontmatter', () => {
|
|
67
77
|
const agentContent = 'Just some content without frontmatter';
|
|
68
78
|
const filePath = join(tempDir, 'invalid-format.md');
|
|
@@ -6,7 +6,8 @@ import { type Agent, AgentSchema } from './schema';
|
|
|
6
6
|
|
|
7
7
|
export function parseAgent(filePath: string): Agent {
|
|
8
8
|
const content = readFileSync(filePath, 'utf8');
|
|
9
|
-
|
|
9
|
+
// Flexible regex to handle both standard and single-line frontmatter
|
|
10
|
+
const match = content.match(/^---[\r\n]*([\s\S]*?)[\r\n]*---(?:\r?\n?([\s\S]*))?$/);
|
|
10
11
|
|
|
11
12
|
if (!match) {
|
|
12
13
|
throw new Error(`Invalid agent format in ${filePath}. Missing frontmatter.`);
|
|
@@ -42,11 +42,19 @@ export const ConfigSchema = z.object({
|
|
|
42
42
|
workflows_directory: z.string().default('workflows'),
|
|
43
43
|
mcp_servers: z
|
|
44
44
|
.record(
|
|
45
|
-
z.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
45
|
+
z.discriminatedUnion('type', [
|
|
46
|
+
z.object({
|
|
47
|
+
type: z.literal('local').default('local'),
|
|
48
|
+
command: z.string(),
|
|
49
|
+
args: z.array(z.string()).optional(),
|
|
50
|
+
env: z.record(z.string()).optional(),
|
|
51
|
+
}),
|
|
52
|
+
z.object({
|
|
53
|
+
type: z.literal('remote'),
|
|
54
|
+
url: z.string().url(),
|
|
55
|
+
headers: z.record(z.string()).optional(),
|
|
56
|
+
}),
|
|
57
|
+
])
|
|
50
58
|
)
|
|
51
59
|
.default({}),
|
|
52
60
|
});
|
|
@@ -180,11 +180,6 @@ export class WorkflowParser {
|
|
|
180
180
|
}
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
-
// Initialize in-degree
|
|
184
|
-
for (const step of workflow.steps) {
|
|
185
|
-
inDegree.set(step.id, 0);
|
|
186
|
-
}
|
|
187
|
-
|
|
188
183
|
// Calculate in-degree
|
|
189
184
|
// In-degree = number of dependencies a step has
|
|
190
185
|
for (const step of workflow.steps) {
|
|
@@ -268,14 +268,6 @@ describe('CopilotAdapter', () => {
|
|
|
268
268
|
await expect(adapter.chat([])).rejects.toThrow(/GitHub Copilot token not found/);
|
|
269
269
|
spy.mockRestore();
|
|
270
270
|
});
|
|
271
|
-
|
|
272
|
-
it('should throw error if token not found (duplicated)', async () => {
|
|
273
|
-
const spy = spyOn(AuthManager, 'getCopilotToken').mockResolvedValue(undefined);
|
|
274
|
-
|
|
275
|
-
const adapter = new CopilotAdapter();
|
|
276
|
-
await expect(adapter.chat([])).rejects.toThrow(/GitHub Copilot token not found/);
|
|
277
|
-
spy.mockRestore();
|
|
278
|
-
});
|
|
279
271
|
});
|
|
280
272
|
|
|
281
273
|
describe('getAdapter', () => {
|
|
@@ -141,19 +141,42 @@ export class AnthropicAdapter implements LLMAdapter {
|
|
|
141
141
|
role: 'assistant',
|
|
142
142
|
content: [
|
|
143
143
|
...(m.content ? [{ type: 'text' as const, text: m.content }] : []),
|
|
144
|
-
...m.tool_calls.map((tc) =>
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
144
|
+
...m.tool_calls.map((tc) => {
|
|
145
|
+
let input = {};
|
|
146
|
+
try {
|
|
147
|
+
input =
|
|
148
|
+
typeof tc.function.arguments === 'string'
|
|
149
|
+
? JSON.parse(tc.function.arguments)
|
|
150
|
+
: tc.function.arguments;
|
|
151
|
+
} catch (e) {
|
|
152
|
+
console.error(`Failed to parse tool arguments: ${tc.function.arguments}`);
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
type: 'tool_use' as const,
|
|
156
|
+
id: tc.id,
|
|
157
|
+
name: tc.function.name,
|
|
158
|
+
input,
|
|
159
|
+
};
|
|
160
|
+
}),
|
|
150
161
|
],
|
|
151
162
|
});
|
|
152
163
|
} else {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
164
|
+
const role = m.role as 'user' | 'assistant';
|
|
165
|
+
const lastMsg = anthropicMessages[anthropicMessages.length - 1];
|
|
166
|
+
|
|
167
|
+
if (
|
|
168
|
+
lastMsg &&
|
|
169
|
+
lastMsg.role === role &&
|
|
170
|
+
typeof lastMsg.content === 'string' &&
|
|
171
|
+
typeof m.content === 'string'
|
|
172
|
+
) {
|
|
173
|
+
lastMsg.content += `\n\n${m.content}`;
|
|
174
|
+
} else {
|
|
175
|
+
anthropicMessages.push({
|
|
176
|
+
role,
|
|
177
|
+
content: m.content || '',
|
|
178
|
+
});
|
|
179
|
+
}
|
|
157
180
|
}
|
|
158
181
|
}
|
|
159
182
|
|