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.
Files changed (57) hide show
  1. package/README.md +69 -16
  2. package/package.json +14 -3
  3. package/src/cli.ts +183 -84
  4. package/src/db/workflow-db.ts +0 -7
  5. package/src/expression/evaluator.test.ts +46 -0
  6. package/src/expression/evaluator.ts +36 -0
  7. package/src/parser/agent-parser.test.ts +10 -0
  8. package/src/parser/agent-parser.ts +13 -5
  9. package/src/parser/config-schema.ts +24 -5
  10. package/src/parser/schema.ts +1 -1
  11. package/src/parser/workflow-parser.ts +5 -9
  12. package/src/runner/llm-adapter.test.ts +0 -8
  13. package/src/runner/llm-adapter.ts +33 -10
  14. package/src/runner/llm-executor.test.ts +230 -96
  15. package/src/runner/llm-executor.ts +9 -4
  16. package/src/runner/mcp-client.test.ts +204 -88
  17. package/src/runner/mcp-client.ts +349 -22
  18. package/src/runner/mcp-manager.test.ts +73 -15
  19. package/src/runner/mcp-manager.ts +84 -18
  20. package/src/runner/mcp-server.test.ts +4 -1
  21. package/src/runner/mcp-server.ts +25 -11
  22. package/src/runner/shell-executor.ts +3 -3
  23. package/src/runner/step-executor.test.ts +2 -2
  24. package/src/runner/step-executor.ts +31 -16
  25. package/src/runner/tool-integration.test.ts +21 -14
  26. package/src/runner/workflow-runner.ts +34 -7
  27. package/src/templates/agents/explore.md +54 -0
  28. package/src/templates/agents/general.md +8 -0
  29. package/src/templates/agents/keystone-architect.md +54 -0
  30. package/src/templates/agents/my-agent.md +3 -0
  31. package/src/templates/agents/summarizer.md +28 -0
  32. package/src/templates/agents/test-agent.md +10 -0
  33. package/src/templates/approval-process.yaml +36 -0
  34. package/src/templates/basic-inputs.yaml +19 -0
  35. package/src/templates/basic-shell.yaml +20 -0
  36. package/src/templates/batch-processor.yaml +43 -0
  37. package/src/templates/cleanup-finally.yaml +22 -0
  38. package/src/templates/composition-child.yaml +13 -0
  39. package/src/templates/composition-parent.yaml +14 -0
  40. package/src/templates/data-pipeline.yaml +38 -0
  41. package/src/templates/full-feature-demo.yaml +64 -0
  42. package/src/templates/human-interaction.yaml +12 -0
  43. package/src/templates/invalid.yaml +5 -0
  44. package/src/templates/llm-agent.yaml +8 -0
  45. package/src/templates/loop-parallel.yaml +37 -0
  46. package/src/templates/retry-policy.yaml +36 -0
  47. package/src/templates/scaffold-feature.yaml +48 -0
  48. package/src/templates/state.db +0 -0
  49. package/src/templates/state.db-shm +0 -0
  50. package/src/templates/state.db-wal +0 -0
  51. package/src/templates/stop-watch.yaml +17 -0
  52. package/src/templates/workflow.db +0 -0
  53. package/src/utils/auth-manager.test.ts +86 -0
  54. package/src/utils/auth-manager.ts +89 -0
  55. package/src/utils/config-loader.test.ts +32 -2
  56. package/src/utils/config-loader.ts +11 -1
  57. 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/` for your automation files.
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
- env:
135
- GITHUB_PERSONAL_ACCESS_TOKEN: "${GITHUB_TOKEN}"
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
- Keystone supports using your GitHub Copilot subscription directly. To authenticate:
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
- 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:
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
- ### Model Context Protocol (MCP)
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
- Keystone supports connecting to external MCP servers to give agents access to a wide range of pre-built tools and resources. You can configure MCP servers globally or directly in an LLM step.
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 by name or path |
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 (defaults to `.keystone/workflows/` or matches a workflow name) |
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 by name or path |
344
- | `config` | Show current configuration and provider settings |
345
- | `auth <login/status/logout>` | Manage GitHub Copilot authentication |
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 Model Context Protocol server |
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 (also automated via `storage.retention_days`) |
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.1.1",
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,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('0.1.0');
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, { resumeRunId: runId });
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
- .command('mcp')
442
- .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)')
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 !== 'github' && provider !== 'copilot') {
507
- console.error(`✗ Unsupported provider: ${provider}`);
508
- process.exit(1);
509
- }
623
+ if (provider === 'github') {
624
+ let token = options.token;
510
625
 
511
- console.log(`🏛️ ${provider === 'copilot' ? 'GitHub Copilot' : 'GitHub'} Login\n`);
626
+ if (!token) {
627
+ try {
628
+ const deviceLogin = await AuthManager.initGitHubDeviceLogin();
512
629
 
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
- });
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
- if (!deviceCodeResponse.ok) {
528
- throw new Error(`GitHub API error: ${deviceCodeResponse.statusText}`);
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
- 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;
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
- if (data.error === 'authorization_pending') {
571
- continue;
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
- throw new Error(`GitHub error: ${data.error}`);
671
+ } catch (e) {
672
+ console.error('\n✗ Failed to verify token:', e instanceof Error ? e.message : e);
575
673
  }
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);
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
- .option('-p, --provider <provider>', 'Authentication provider')
624
- .action(async (options) => {
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({
@@ -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
  });