keystone-cli 0.7.1 → 0.8.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
@@ -372,6 +372,16 @@ You are a technical communications expert. Your goal is to take technical output
372
372
 
373
373
  Agents can be equipped with tools, which are essentially workflow steps they can choose to execute. You can define tools in the agent definition, or directly in an LLM step within a workflow.
374
374
 
375
+ Keystone comes with a set of **Standard Tools** that can be enabled for any agent by setting `useStandardTools: true` in the step definition:
376
+
377
+ - `read_file`: Read the contents of a file (arguments: `path`)
378
+ - `read_file_lines`: Read a specific range of lines from a file (arguments: `path`, `start`, `count`)
379
+ - `write_file`: Write or overwrite a file (arguments: `path`, `content`)
380
+ - `list_files`: List files in a directory (arguments: `path`)
381
+ - `search_files`: Search for files by glob pattern (arguments: `pattern`, `dir`)
382
+ - `search_content`: Search for string or regex within files (arguments: `query`, `dir`, `pattern`)
383
+ - `run_command`: Run a shell command (arguments: `command`, `dir`). Requires `allowInsecure: true` on the step unless whitelisted.
384
+
375
385
  Tool arguments are passed to the tool's execution step via the `args` variable.
376
386
 
377
387
  **`.keystone/workflows/agents/developer.md`**
@@ -379,28 +389,25 @@ Tool arguments are passed to the tool's execution step via the `args` variable.
379
389
  ---
380
390
  name: developer
381
391
  tools:
382
- - name: list_files
383
- description: List files in the current directory
392
+ - name: custom_tool
393
+ description: A custom tool definition
384
394
  execution:
385
- id: list-files-tool
386
395
  type: shell
387
- run: ls -F
388
- - name: read_file
389
- description: Read a specific file
390
- parameters:
391
- type: object
392
- properties:
393
- path: { type: string }
394
- required: [path]
395
- execution:
396
- id: read-file-tool
397
- type: file
398
- op: read
399
- path: ${{ args.path }}
396
+ run: echo "custom"
400
397
  ---
401
398
  You are a software developer. You can use tools to explore the codebase.
402
399
  ```
403
400
 
401
+ To enable standard tools in a workflow step:
402
+
403
+ ```yaml
404
+ - id: explore
405
+ type: llm
406
+ agent: developer
407
+ useStandardTools: true
408
+ prompt: "Explore the src directory"
409
+ ```
410
+
404
411
  ### Keystone as an MCP Server
405
412
 
406
413
  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.
@@ -480,9 +487,9 @@ In these examples, the agent will have access to all tools provided by the MCP s
480
487
  | Command | Description |
481
488
  | :--- | :--- |
482
489
  | `init` | Initialize a new Keystone project |
483
- | `run <workflow>` | Execute a workflow (use `-i key=val` for inputs, `--dry-run` to test, `--debug` for REPL) |
490
+ | `run <workflow>` | Execute a workflow (use `-i key=val`, `--resume` to auto-resume, `--dry-run`, `--debug`) |
484
491
  | `optimize <workflow>` | Optimize a specific step in a workflow (requires --target) |
485
- | `resume <run_id>` | Resume a failed or paused workflow |
492
+ | `resume <run_id>` | Resume a failed/paused/crashed workflow by ID |
486
493
  | `validate [path]` | Check workflow files for errors |
487
494
  | `workflows` | List available workflows |
488
495
  | `history` | Show recent workflow runs |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keystone-cli",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "description": "A local-first, declarative, agentic workflow orchestrator built on Bun",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.ts CHANGED
@@ -228,7 +228,8 @@ program
228
228
  .option('-i, --input <key=value...>', 'Input values')
229
229
  .option('--dry-run', 'Show what would be executed without actually running it')
230
230
  .option('--debug', 'Enable interactive debug mode on failure')
231
- .action(async (workflowPath, options) => {
231
+ .option('--resume', 'Resume the last run of this workflow if it failed or was paused')
232
+ .action(async (workflowPathArg, options) => {
232
233
  // Parse inputs
233
234
  const inputs: Record<string, unknown> = {};
234
235
  if (options.input) {
@@ -249,17 +250,47 @@ program
249
250
 
250
251
  // Load and validate workflow
251
252
  try {
252
- const resolvedPath = WorkflowRegistry.resolvePath(workflowPath);
253
+ const resolvedPath = WorkflowRegistry.resolvePath(workflowPathArg);
253
254
  const workflow = WorkflowParser.loadWorkflow(resolvedPath);
254
255
 
255
256
  // Import WorkflowRunner dynamically
256
257
  const { WorkflowRunner } = await import('./runner/workflow-runner.ts');
257
258
  const logger = new ConsoleLogger();
259
+
260
+ let resumeRunId: string | undefined;
261
+
262
+ // Handle auto-resume
263
+ if (options.resume) {
264
+ const db = new WorkflowDb();
265
+ const lastRun = await db.getLastRun(workflow.name);
266
+ db.close();
267
+
268
+ if (lastRun) {
269
+ if (
270
+ lastRun.status === 'failed' ||
271
+ lastRun.status === 'paused' ||
272
+ lastRun.status === 'running'
273
+ ) {
274
+ resumeRunId = lastRun.id;
275
+ console.log(
276
+ `Resuming run ${lastRun.id} (status: ${lastRun.status}) from ${new Date(
277
+ lastRun.started_at
278
+ ).toLocaleString()}`
279
+ );
280
+ } else {
281
+ console.log(`Last run ${lastRun.id} completed successfully. Starting new run.`);
282
+ }
283
+ } else {
284
+ console.log('No previous run found. Starting new run.');
285
+ }
286
+ }
287
+
258
288
  const runner = new WorkflowRunner(workflow, {
259
289
  inputs,
260
290
  workflowDir: dirname(resolvedPath),
261
291
  dryRun: !!options.dryRun,
262
292
  debug: !!options.debug,
293
+ resumeRunId,
263
294
  logger,
264
295
  });
265
296
 
@@ -342,6 +342,21 @@ export class WorkflowDb {
342
342
  });
343
343
  }
344
344
 
345
+ /**
346
+ * Get the most recent run for a specific workflow
347
+ */
348
+ async getLastRun(workflowName: string): Promise<WorkflowRun | null> {
349
+ return this.withRetry(() => {
350
+ const stmt = this.db.prepare(`
351
+ SELECT * FROM workflow_runs
352
+ WHERE workflow_name = ?
353
+ ORDER BY started_at DESC
354
+ LIMIT 1
355
+ `);
356
+ return stmt.get(workflowName) as WorkflowRun | null;
357
+ });
358
+ }
359
+
345
360
  close(): void {
346
361
  this.db.close();
347
362
  }
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import * as vm from 'node:vm';
3
+ import { STANDARD_TOOLS } from './standard-tools';
4
+
5
+ describe('Standard Tools Execution Verification', () => {
6
+ const scriptTools = STANDARD_TOOLS.filter(
7
+ (t) => t.execution && t.execution.type === 'script' && typeof t.execution.run === 'string'
8
+ );
9
+
10
+ for (const tool of scriptTools) {
11
+ it(`should compile and execute ${tool.name} without SyntaxError`, () => {
12
+ const script = tool.execution.run as string;
13
+ const sandbox = {
14
+ args: { path: '.', pattern: '*', query: 'test' },
15
+ require: (mod: string) => {
16
+ if (mod === 'node:fs' || mod === 'fs') {
17
+ return {
18
+ existsSync: () => true,
19
+ readdirSync: () => [],
20
+ statSync: () => ({ size: 0 }),
21
+ readFileSync: () => '',
22
+ };
23
+ }
24
+ if (mod === 'node:path' || mod === 'path') {
25
+ return { join: (...args: string[]) => args.join('/') };
26
+ }
27
+ if (mod === 'glob') {
28
+ return { globSync: () => [] };
29
+ }
30
+ return {};
31
+ },
32
+ };
33
+
34
+ expect(() => {
35
+ vm.runInNewContext(script, sandbox);
36
+ }).not.toThrow();
37
+ });
38
+ }
39
+ });
@@ -38,19 +38,21 @@ export const STANDARD_TOOLS: AgentTool[] = [
38
38
  id: 'std_read_file_lines',
39
39
  type: 'script',
40
40
  run: `
41
- const fs = require('node:fs');
42
- const path = require('node:path');
43
- const filePath = args.path;
44
- const start = args.start || 1;
45
- const count = args.count || 100;
46
-
47
- if (!fs.existsSync(filePath)) {
48
- throw new Error('File not found: ' + filePath);
49
- }
50
-
51
- const content = fs.readFileSync(filePath, 'utf8');
52
- const lines = content.split('\\n');
53
- return lines.slice(start - 1, start - 1 + count).join('\\n');
41
+ (function() {
42
+ const fs = require('node:fs');
43
+ const path = require('node:path');
44
+ const filePath = args.path;
45
+ const start = args.start || 1;
46
+ const count = args.count || 100;
47
+
48
+ if (!fs.existsSync(filePath)) {
49
+ throw new Error('File not found: ' + filePath);
50
+ }
51
+
52
+ const content = fs.readFileSync(filePath, 'utf8');
53
+ const lines = content.split('\\n');
54
+ return lines.slice(start - 1, start - 1 + count).join('\\n');
55
+ })();
54
56
  `,
55
57
  allowInsecure: true,
56
58
  },
@@ -91,18 +93,20 @@ export const STANDARD_TOOLS: AgentTool[] = [
91
93
  id: 'std_list_files',
92
94
  type: 'script',
93
95
  run: `
94
- const fs = require('node:fs');
95
- const path = require('node:path');
96
- const dir = args.path || '.';
97
- if (fs.existsSync(dir)) {
98
- const files = fs.readdirSync(dir, { withFileTypes: true });
99
- return files.map(f => ({
100
- name: f.name,
101
- type: f.isDirectory() ? 'directory' : 'file',
102
- size: f.isFile() ? fs.statSync(path.join(dir, f.name)).size : undefined
103
- }));
104
- }
105
- throw new Error('Directory not found: ' + dir);
96
+ (function() {
97
+ const fs = require('node:fs');
98
+ const path = require('node:path');
99
+ const dir = args.path || '.';
100
+ if (fs.existsSync(dir)) {
101
+ const files = fs.readdirSync(dir, { withFileTypes: true });
102
+ return files.map(f => ({
103
+ name: f.name,
104
+ type: f.isDirectory() ? 'directory' : 'file',
105
+ size: f.isFile() ? fs.statSync(path.join(dir, f.name)).size : undefined
106
+ }));
107
+ }
108
+ throw new Error('Directory not found: ' + dir);
109
+ })();
106
110
  `,
107
111
  allowInsecure: true,
108
112
  },
@@ -122,16 +126,18 @@ export const STANDARD_TOOLS: AgentTool[] = [
122
126
  id: 'std_search_files',
123
127
  type: 'script',
124
128
  run: `
125
- const fs = require('node:fs');
126
- const path = require('node:path');
127
- const { globSync } = require('glob');
128
- const dir = args.dir || '.';
129
- const pattern = args.pattern;
130
- try {
131
- return globSync(pattern, { cwd: dir, nodir: true });
132
- } catch (e) {
133
- throw new Error('Search failed: ' + e.message);
134
- }
129
+ (function() {
130
+ const fs = require('node:fs');
131
+ const path = require('node:path');
132
+ const { globSync } = require('glob');
133
+ const dir = args.dir || '.';
134
+ const pattern = args.pattern;
135
+ try {
136
+ return globSync(pattern, { cwd: dir, nodir: true });
137
+ } catch (e) {
138
+ throw new Error('Search failed: ' + e.message);
139
+ }
140
+ })();
135
141
  `,
136
142
  allowInsecure: true,
137
143
  },
@@ -156,42 +162,44 @@ export const STANDARD_TOOLS: AgentTool[] = [
156
162
  id: 'std_search_content',
157
163
  type: 'script',
158
164
  run: `
159
- const fs = require('node:fs');
160
- const path = require('node:path');
161
- const { globSync } = require('glob');
162
- const dir = args.dir || '.';
163
- const pattern = args.pattern || '**/*';
164
- const query = args.query;
165
- if (query.length > 500) {
166
- throw new Error('Search query exceeds maximum length of 500 characters');
167
- }
168
- const isRegex = query.startsWith('/') && query.endsWith('/');
169
- let regex;
170
- try {
171
- regex = isRegex ? new RegExp(query.slice(1, -1)) : new RegExp(query.replace(/[.*+?^$\\{}()|[\\]\\\\]/g, '\\\\$&'), 'i');
172
- } catch (e) {
173
- throw new Error('Invalid regular expression: ' + e.message);
174
- }
175
-
176
- const files = globSync(pattern, { cwd: dir, nodir: true });
177
- const results = [];
178
- for (const file of files) {
179
- const fullPath = path.join(dir, file);
180
- const content = fs.readFileSync(fullPath, 'utf8');
181
- const lines = content.split('\\n');
182
- for (let i = 0; i < lines.length; i++) {
183
- if (regex.test(lines[i])) {
184
- results.push({
185
- file,
186
- line: i + 1,
187
- content: lines[i].trim()
188
- });
165
+ (function() {
166
+ const fs = require('node:fs');
167
+ const path = require('node:path');
168
+ const { globSync } = require('glob');
169
+ const dir = args.dir || '.';
170
+ const pattern = args.pattern || '**/*';
171
+ const query = args.query;
172
+ if (query.length > 500) {
173
+ throw new Error('Search query exceeds maximum length of 500 characters');
174
+ }
175
+ const isRegex = query.startsWith('/') && query.endsWith('/');
176
+ let regex;
177
+ try {
178
+ regex = isRegex ? new RegExp(query.slice(1, -1)) : new RegExp(query.replace(/[.*+?^$\\{}()|[\\]\\\\]/g, '\\\\$&'), 'i');
179
+ } catch (e) {
180
+ throw new Error('Invalid regular expression: ' + e.message);
181
+ }
182
+
183
+ const files = globSync(pattern, { cwd: dir, nodir: true });
184
+ const results = [];
185
+ for (const file of files) {
186
+ const fullPath = path.join(dir, file);
187
+ const content = fs.readFileSync(fullPath, 'utf8');
188
+ const lines = content.split('\\n');
189
+ for (let i = 0; i < lines.length; i++) {
190
+ if (regex.test(lines[i])) {
191
+ results.push({
192
+ file,
193
+ line: i + 1,
194
+ content: lines[i].trim()
195
+ });
196
+ }
197
+ if (results.length > 100) break; // Limit results
189
198
  }
190
- if (results.length > 100) break; // Limit results
199
+ if (results.length > 100) break;
191
200
  }
192
- if (results.length > 100) break;
193
- }
194
- return results;
201
+ return results;
202
+ })();
195
203
  `,
196
204
  allowInsecure: true,
197
205
  },
@@ -18,6 +18,7 @@ import { getAdapter } from './llm-adapter.ts';
18
18
  import { detectShellInjectionRisk, executeShell } from './shell-executor.ts';
19
19
 
20
20
  import * as fs from 'node:fs';
21
+ import { createRequire } from 'node:module';
21
22
  import * as os from 'node:os';
22
23
  import * as path from 'node:path';
23
24
  import * as readline from 'node:readline/promises';
@@ -543,6 +544,8 @@ async function executeScriptStep(
543
544
  );
544
545
  }
545
546
 
547
+ const requireFn = createRequire(import.meta.url);
548
+
546
549
  const result = await sandbox.execute(
547
550
  step.run,
548
551
  {
@@ -550,6 +553,10 @@ async function executeScriptStep(
550
553
  secrets: context.secrets,
551
554
  steps: context.steps,
552
555
  env: context.env,
556
+ // biome-ignore lint/suspicious/noExplicitAny: args is dynamic
557
+ args: (context as any).args,
558
+ require: requireFn,
559
+ console,
553
560
  },
554
561
  {
555
562
  timeout: step.timeout,
@@ -115,6 +115,8 @@ describe('WorkflowRunner', () => {
115
115
  },
116
116
  error: (msg: string) => console.error(msg),
117
117
  warn: (msg: string) => console.warn(msg),
118
+ info: (msg: string) => {},
119
+ debug: (msg: string) => {},
118
120
  };
119
121
 
120
122
  const finallyWorkflow: Workflow = {
@@ -487,4 +489,59 @@ describe('WorkflowRunner', () => {
487
489
 
488
490
  if (existsSync(resumeDbPath)) rmSync(resumeDbPath);
489
491
  });
492
+
493
+ it('should resume a workflow marked as running (crashed process)', async () => {
494
+ const resumeDbPath = 'test-running-resume.db';
495
+ if (existsSync(resumeDbPath)) rmSync(resumeDbPath);
496
+
497
+ const workflow: Workflow = {
498
+ name: 'running-wf',
499
+ steps: [
500
+ { id: 's1', type: 'shell', run: 'echo "one"', needs: [] },
501
+ { id: 's2', type: 'shell', run: 'echo "two"', needs: ['s1'] },
502
+ ],
503
+ outputs: {
504
+ out: '${{ steps.s1.output.stdout.trim() }}-${{ steps.s2.output.stdout.trim() }}',
505
+ },
506
+ } as unknown as Workflow;
507
+
508
+ // Manually create a "running" state in the DB
509
+ const db = new WorkflowDb(resumeDbPath);
510
+ const runId = crypto.randomUUID();
511
+ await db.createRun(runId, workflow.name, {});
512
+ await db.updateRunStatus(runId, 'running');
513
+
514
+ // Create a completed step 1
515
+ const step1Id = crypto.randomUUID();
516
+ await db.createStep(step1Id, runId, 's1');
517
+ await db.completeStep(step1Id, 'success', { stdout: 'one\n', stderr: '', exitCode: 0 });
518
+ db.close();
519
+
520
+ // Verify warnings
521
+ let warningLogged = false;
522
+ const logger = {
523
+ log: () => {},
524
+ error: () => {},
525
+ warn: (msg: string) => {
526
+ if (msg.includes("Resuming a run marked as 'running'")) {
527
+ warningLogged = true;
528
+ }
529
+ },
530
+ info: () => {},
531
+ debug: () => {},
532
+ };
533
+
534
+ const runner = new WorkflowRunner(workflow, {
535
+ dbPath: resumeDbPath,
536
+ resumeRunId: runId,
537
+ // @ts-ignore
538
+ logger: logger,
539
+ });
540
+
541
+ const outputs = await runner.run();
542
+ expect(outputs.out).toBe('one-two');
543
+ expect(warningLogged).toBe(true);
544
+
545
+ if (existsSync(resumeDbPath)) rmSync(resumeDbPath);
546
+ });
490
547
  });
@@ -161,10 +161,20 @@ export class WorkflowRunner {
161
161
  throw new Error(`Run ${this.runId} not found`);
162
162
  }
163
163
 
164
- // Only allow resuming failed or paused runs
165
- if (run.status !== WorkflowStatus.FAILED && run.status !== WorkflowStatus.PAUSED) {
164
+ // Only allow resuming failed, paused, or running (crash recovery) runs
165
+ if (
166
+ run.status !== WorkflowStatus.FAILED &&
167
+ run.status !== WorkflowStatus.PAUSED &&
168
+ run.status !== WorkflowStatus.RUNNING
169
+ ) {
166
170
  throw new Error(
167
- `Cannot resume run with status '${run.status}'. Only 'failed' or 'paused' runs can be resumed.`
171
+ `Cannot resume run with status '${run.status}'. Only 'failed', 'paused', or 'running' runs can be resumed.`
172
+ );
173
+ }
174
+
175
+ if (run.status === WorkflowStatus.RUNNING) {
176
+ this.logger.warn(
177
+ `⚠️ Resuming a run marked as 'running'. This usually means the previous process crashed or was killed forcefully. Ensure no other instances are running.`
168
178
  );
169
179
  }
170
180