keystone-cli 0.6.0 → 0.7.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
@@ -260,6 +260,23 @@ finally:
260
260
  type: shell
261
261
  run: echo "Workflow finished"
262
262
 
263
+ ### Expression Syntax
264
+
265
+ Keystone uses `${{ }}` syntax for dynamic values. Expressions are evaluated using a safe AST parser.
266
+
267
+ - `${{ inputs.name }}`: Access workflow inputs.
268
+ - `${{ steps.id.output }}`: Access the raw output of a previous step.
269
+ - `${{ steps.id.outputs.field }}`: Access specific fields if the output is an object.
270
+ - `${{ steps.id.status }}`: Get the execution status of a step (`'success'`, `'failed'`, etc.).
271
+ - `${{ item }}`: Access the current item in a `foreach` loop.
272
+ - `${{ args.name }}`: Access tool arguments (available ONLY inside agent tool execution steps).
273
+ - `${{ secrets.NAME }}`: Access redacted secrets.
274
+ - `${{ env.NAME }}`: Access environment variables.
275
+
276
+ Standard JavaScript-like expressions are supported: `${{ steps.build.status == 'success' ? 'šŸš€' : 'āŒ' }}`.
277
+
278
+ ---
279
+
263
280
  outputs:
264
281
  slack_message: ${{ steps.notify.output }}
265
282
  ```
@@ -274,8 +291,11 @@ Keystone supports several specialized step types:
274
291
  - `llm`: Prompt an agent and get structured or unstructured responses. Supports `schema` (JSON Schema) for structured output.
275
292
  - `allowClarification`: Boolean (default `false`). If `true`, allows the LLM to ask clarifying questions back to the user or suspend the workflow if no human is available.
276
293
  - `maxIterations`: Number (default `10`). Maximum number of tool-calling loops allowed for the agent.
294
+ - `allowInsecure`: Boolean (default `false`). Set `true` to allow risky tool execution.
295
+ - `allowOutsideCwd`: Boolean (default `false`). Set `true` to allow tools to access files outside of the current working directory.
277
296
  - `request`: Make HTTP requests (GET, POST, etc.).
278
297
  - `file`: Read, write, or append to files.
298
+ - `allowOutsideCwd`: Boolean (default `false`). Set `true` to allow reading/writing files outside of the current working directory.
279
299
  - `human`: Pause execution for manual confirmation or text input.
280
300
  - `inputType: confirm`: Simple Enter-to-continue prompt.
281
301
  - `inputType: text`: Prompt for a string input, available via `${{ steps.id.output }}`.
@@ -352,6 +372,8 @@ You are a technical communications expert. Your goal is to take technical output
352
372
 
353
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.
354
374
 
375
+ Tool arguments are passed to the tool's execution step via the `args` variable.
376
+
355
377
  **`.keystone/workflows/agents/developer.md`**
356
378
  ```markdown
357
379
  ---
@@ -363,6 +385,18 @@ tools:
363
385
  id: list-files-tool
364
386
  type: shell
365
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 }}
366
400
  ---
367
401
  You are a software developer. You can use tools to explore the codebase.
368
402
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keystone-cli",
3
- "version": "0.6.0",
3
+ "version": "0.7.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
@@ -9,7 +9,7 @@ import architectAgent from './templates/agents/keystone-architect.md' with { typ
9
9
  // Default templates
10
10
  import scaffoldWorkflow from './templates/scaffold-feature.yaml' with { type: 'text' };
11
11
 
12
- import { WorkflowDb } from './db/workflow-db.ts';
12
+ import { WorkflowDb, type WorkflowRun } from './db/workflow-db.ts';
13
13
  import { WorkflowParser } from './parser/workflow-parser.ts';
14
14
  import { ConfigLoader } from './utils/config-loader.ts';
15
15
  import { ConsoleLogger } from './utils/logger.ts';
@@ -279,7 +279,79 @@ program
279
279
  }
280
280
  });
281
281
 
282
- // ... (optimize command remains here) ...
282
+ // ===== keystone workflows =====
283
+ program
284
+ .command('workflows')
285
+ .description('List available workflows')
286
+ .action(() => {
287
+ const workflows = WorkflowRegistry.listWorkflows();
288
+ if (workflows.length === 0) {
289
+ console.log('No workflows found. Run "keystone init" to seed default workflows.');
290
+ return;
291
+ }
292
+
293
+ console.log('\nšŸ›ļø Available Workflows:');
294
+ for (const w of workflows) {
295
+ console.log(`\n ${w.name}`);
296
+ if (w.description) {
297
+ console.log(` ${w.description}`);
298
+ }
299
+ }
300
+ console.log('');
301
+ });
302
+
303
+ // ===== keystone optimize =====
304
+ program
305
+ .command('optimize')
306
+ .description('Optimize a specific step in a workflow using iterative evaluation')
307
+ .argument('<workflow>', 'Workflow name or path to workflow file')
308
+ .requiredOption('-t, --target <step_id>', 'Target step ID to optimize')
309
+ .option('-n, --iterations <number>', 'Number of optimization iterations', '5')
310
+ .option('-i, --input <key=value...>', 'Input values for evaluation')
311
+ .action(async (workflowPath, options) => {
312
+ try {
313
+ const { OptimizationRunner } = await import('./runner/optimization-runner.ts');
314
+ const resolvedPath = WorkflowRegistry.resolvePath(workflowPath);
315
+ const workflow = WorkflowParser.loadWorkflow(resolvedPath);
316
+
317
+ // Parse inputs
318
+ const inputs: Record<string, unknown> = {};
319
+ if (options.input) {
320
+ for (const pair of options.input) {
321
+ const index = pair.indexOf('=');
322
+ if (index > 0) {
323
+ const key = pair.slice(0, index);
324
+ const value = pair.slice(index + 1);
325
+ try {
326
+ inputs[key] = JSON.parse(value);
327
+ } catch {
328
+ inputs[key] = value;
329
+ }
330
+ }
331
+ }
332
+ }
333
+
334
+ const runner = new OptimizationRunner(workflow, {
335
+ workflowPath: resolvedPath,
336
+ targetStepId: options.target,
337
+ iterations: Number.parseInt(options.iterations, 10),
338
+ inputs,
339
+ });
340
+
341
+ console.log('šŸ›ļø Keystone Prompt Optimization');
342
+ const { bestPrompt, bestScore } = await runner.optimize();
343
+
344
+ console.log('\n✨ Optimization Complete!');
345
+ console.log(`šŸ† Best Score: ${bestScore}/100`);
346
+ console.log('\nBest Prompt/Command:');
347
+ console.log(''.padEnd(80, '-'));
348
+ console.log(bestPrompt);
349
+ console.log(''.padEnd(80, '-'));
350
+ } catch (error) {
351
+ console.error('āœ— Optimization failed:', error instanceof Error ? error.message : error);
352
+ process.exit(1);
353
+ }
354
+ });
283
355
 
284
356
  // ===== keystone resume =====
285
357
  program
@@ -347,40 +419,180 @@ program
347
419
  }
348
420
  });
349
421
 
350
- // ... (other commands) ...
351
-
352
- // ===== keystone maintenance =====
422
+ // ===== keystone history =====
353
423
  program
354
- .command('maintenance')
355
- .description('Perform database maintenance (prune old runs and vacuum)')
356
- .option('--days <days>', 'Delete runs older than this many days', '30')
424
+ .command('history')
425
+ .description('Show recent workflow runs')
426
+ .option('-l, --limit <number>', 'Limit the number of runs to show', '50')
357
427
  .action(async (options) => {
358
428
  try {
359
- const days = Number.parseInt(options.days, 10);
360
- if (Number.isNaN(days) || days < 0) {
361
- console.error('āœ— Invalid days value. Must be a positive number.');
362
- process.exit(1);
429
+ const db = new WorkflowDb();
430
+ const limit = Number.parseInt(options.limit, 10);
431
+ const runs = await db.listRuns(limit);
432
+ db.close();
433
+
434
+ if (runs.length === 0) {
435
+ console.log('No workflow runs found.');
436
+ return;
363
437
  }
364
438
 
365
- console.log('🧹 Starting maintenance...');
439
+ console.log('\nšŸ›ļø Workflow Run History:');
440
+ console.log(''.padEnd(100, '-'));
441
+ console.log(
442
+ `${'ID'.padEnd(10)} ${'Workflow'.padEnd(25)} ${'Status'.padEnd(15)} ${'Started At'}`
443
+ );
444
+ console.log(''.padEnd(100, '-'));
445
+
446
+ for (const run of runs) {
447
+ const id = run.id.slice(0, 8);
448
+ const status = run.status;
449
+ const color =
450
+ status === 'success' ? '\x1b[32m' : status === 'failed' ? '\x1b[31m' : '\x1b[33m';
451
+ const reset = '\x1b[0m';
452
+
453
+ console.log(
454
+ `${id.padEnd(10)} ${run.workflow_name.padEnd(25)} ${color}${status.padEnd(
455
+ 15
456
+ )}${reset} ${new Date(run.started_at).toLocaleString()}`
457
+ );
458
+ }
459
+ console.log('');
460
+ } catch (error) {
461
+ console.error('āœ— Failed to list runs:', error instanceof Error ? error.message : error);
462
+ process.exit(1);
463
+ }
464
+ });
465
+
466
+ // ===== keystone logs =====
467
+ program
468
+ .command('logs')
469
+ .description('Show logs for a specific workflow run')
470
+ .argument('<run_id>', 'Run ID to show logs for')
471
+ .option('-v, --verbose', 'Show detailed step outputs')
472
+ .action(async (runId, options) => {
473
+ try {
366
474
  const db = new WorkflowDb();
475
+ const run = await db.getRun(runId);
367
476
 
368
- console.log(` Pruning runs older than ${days} days...`);
369
- const deleted = await db.pruneRuns(days);
370
- console.log(` āœ“ Deleted ${deleted} run(s)`);
477
+ if (!run) {
478
+ // Try searching by short ID
479
+ const allRuns = await db.listRuns(200);
480
+ const matching = allRuns.find((r) => r.id.startsWith(runId));
481
+ if (matching) {
482
+ const detailedRun = await db.getRun(matching.id);
483
+ if (detailedRun) {
484
+ await showRunLogs(detailedRun, db, !!options.verbose);
485
+ db.close();
486
+ return;
487
+ }
488
+ }
371
489
 
372
- console.log(' Vacuuming database (reclaiming space)...');
373
- await db.vacuum();
374
- console.log(' āœ“ Vacuum complete');
490
+ console.error(`āœ— Run not found: ${runId}`);
491
+ db.close();
492
+ process.exit(1);
493
+ }
375
494
 
495
+ await showRunLogs(run, db, !!options.verbose);
376
496
  db.close();
377
- console.log('\n✨ Maintenance completed successfully!');
378
497
  } catch (error) {
379
- console.error('āœ— Maintenance failed:', error instanceof Error ? error.message : error);
498
+ console.error('āœ— Failed to show logs:', error instanceof Error ? error.message : error);
380
499
  process.exit(1);
381
500
  }
382
501
  });
383
502
 
503
+ async function showRunLogs(run: WorkflowRun, db: WorkflowDb, verbose: boolean) {
504
+ console.log(`\nšŸ›ļø Run: ${run.workflow_name} (${run.id})`);
505
+ console.log(` Status: ${run.status}`);
506
+ console.log(` Started: ${new Date(run.started_at).toLocaleString()}`);
507
+ if (run.completed_at) {
508
+ console.log(` Completed: ${new Date(run.completed_at).toLocaleString()}`);
509
+ }
510
+
511
+ const steps = await db.getStepsByRun(run.id);
512
+ console.log(`\nSteps (${steps.length}):`);
513
+ console.log(''.padEnd(100, '-'));
514
+
515
+ for (const step of steps) {
516
+ const statusColor =
517
+ step.status === 'success' ? '\x1b[32m' : step.status === 'failed' ? '\x1b[31m' : '\x1b[33m';
518
+ const reset = '\x1b[0m';
519
+
520
+ let label = step.step_id;
521
+ if (step.iteration_index !== null) {
522
+ label += ` [${step.iteration_index}]`;
523
+ }
524
+
525
+ console.log(`${statusColor}${step.status.toUpperCase().padEnd(10)}${reset} ${label}`);
526
+
527
+ if (step.error) {
528
+ console.log(` \x1b[31mError: ${step.error}\x1b[0m`);
529
+ }
530
+
531
+ if (verbose && step.output) {
532
+ try {
533
+ const output = JSON.parse(step.output);
534
+ console.log(
535
+ ` Output: ${JSON.stringify(output, null, 2).replace(/\n/g, '\n ')}`
536
+ );
537
+ } catch {
538
+ console.log(` Output: ${step.output}`);
539
+ }
540
+ }
541
+ }
542
+
543
+ if (run.outputs) {
544
+ console.log('\nFinal Outputs:');
545
+ try {
546
+ const parsed = JSON.parse(run.outputs);
547
+ console.log(JSON.stringify(parsed, null, 2));
548
+ } catch {
549
+ console.log(run.outputs);
550
+ }
551
+ }
552
+
553
+ if (run.error) {
554
+ console.log(`\n\x1b[31mWorkflow Error:\x1b[0m ${run.error}`);
555
+ }
556
+ }
557
+
558
+ // ===== keystone prune / maintenance =====
559
+ async function performMaintenance(days: number) {
560
+ try {
561
+ console.log(`🧹 Starting maintenance (pruning runs older than ${days} days)...`);
562
+ const db = new WorkflowDb();
563
+ const count = await db.pruneRuns(days);
564
+ console.log(` āœ“ Pruned ${count} old run(s)`);
565
+
566
+ console.log(' Vacuuming database (reclaiming space)...');
567
+ await db.vacuum();
568
+ console.log(' āœ“ Vacuum complete');
569
+
570
+ db.close();
571
+ console.log('\n✨ Maintenance completed successfully!');
572
+ } catch (error) {
573
+ console.error('āœ— Maintenance failed:', error instanceof Error ? error.message : error);
574
+ process.exit(1);
575
+ }
576
+ }
577
+
578
+ program
579
+ .command('prune')
580
+ .description('Delete old workflow runs from the database (alias for maintenance)')
581
+ .option('--days <number>', 'Days to keep', '30')
582
+ .action(async (options) => {
583
+ const days = Number.parseInt(options.days, 10);
584
+ await performMaintenance(days);
585
+ });
586
+
587
+ program
588
+ .command('maintenance')
589
+ .description('Perform database maintenance (prune old runs and vacuum)')
590
+ .option('--days <days>', 'Delete runs older than this many days', '30')
591
+ .action(async (options) => {
592
+ const days = Number.parseInt(options.days, 10);
593
+ await performMaintenance(days);
594
+ });
595
+
384
596
  // ===== keystone ui =====
385
597
  program
386
598
  .command('ui')
@@ -1,5 +1,7 @@
1
1
  import type { Database } from 'bun:sqlite';
2
2
  import { randomUUID } from 'node:crypto';
3
+ import { existsSync, mkdirSync } from 'node:fs';
4
+ import { dirname } from 'node:path';
3
5
  import * as sqliteVec from 'sqlite-vec';
4
6
  import './sqlite-setup.ts';
5
7
 
@@ -22,6 +24,10 @@ export class MemoryDb {
22
24
  this.db = cached.db;
23
25
  } else {
24
26
  const { Database } = require('bun:sqlite');
27
+ const dir = dirname(dbPath);
28
+ if (!existsSync(dir)) {
29
+ mkdirSync(dir, { recursive: true });
30
+ }
25
31
  this.db = new Database(dbPath, { create: true });
26
32
 
27
33
  // Load sqlite-vec extension
@@ -0,0 +1,47 @@
1
+ import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test';
2
+ import type { Logger } from '../utils/logger';
3
+ import { setupSqlite } from './sqlite-setup';
4
+
5
+ describe('setupSqlite', () => {
6
+ const originalPlatform = process.platform;
7
+
8
+ afterEach(() => {
9
+ Object.defineProperty(process, 'platform', {
10
+ value: originalPlatform,
11
+ });
12
+ });
13
+
14
+ it('does nothing on non-darwin platforms', () => {
15
+ Object.defineProperty(process, 'platform', { value: 'linux' });
16
+ const logger: Logger = {
17
+ log: mock(() => {}),
18
+ warn: mock(() => {}),
19
+ error: mock(() => {}),
20
+ info: mock(() => {}),
21
+ };
22
+ setupSqlite(logger);
23
+ expect(logger.log).not.toHaveBeenCalled();
24
+ expect(logger.warn).not.toHaveBeenCalled();
25
+ });
26
+
27
+ it('logs warning if no custom sqlite found on darwin', () => {
28
+ Object.defineProperty(process, 'platform', { value: 'darwin' });
29
+ const logger: Logger = {
30
+ log: mock(() => {}),
31
+ warn: mock(() => {}),
32
+ error: mock(() => {}),
33
+ info: mock(() => {}),
34
+ };
35
+
36
+ // Mock Bun.spawnSync for brew
37
+ const spawnSpy = spyOn(Bun, 'spawnSync').mockImplementation(
38
+ () => ({ success: false }) as unknown as ReturnType<typeof Bun.spawnSync>
39
+ );
40
+
41
+ try {
42
+ setupSqlite(logger);
43
+ } finally {
44
+ spawnSpy.mockRestore();
45
+ }
46
+ });
47
+ });
@@ -1,4 +1,6 @@
1
1
  import { Database } from 'bun:sqlite';
2
+ import { existsSync, mkdirSync } from 'node:fs';
3
+ import { dirname } from 'node:path';
2
4
  import './sqlite-setup.ts';
3
5
  import {
4
6
  StepStatus as StepStatusConst,
@@ -40,6 +42,10 @@ export class WorkflowDb {
40
42
  private db: Database;
41
43
 
42
44
  constructor(public readonly dbPath = '.keystone/state.db') {
45
+ const dir = dirname(dbPath);
46
+ if (!existsSync(dir)) {
47
+ mkdirSync(dir, { recursive: true });
48
+ }
43
49
  this.db = new Database(dbPath, { create: true });
44
50
  this.db.exec('PRAGMA journal_mode = WAL;'); // Write-ahead logging
45
51
  this.db.exec('PRAGMA foreign_keys = ON;'); // Enable foreign key enforcement
@@ -29,6 +29,7 @@ export interface ExpressionContext {
29
29
  secrets?: Record<string, string>;
30
30
  steps?: Record<string, { output?: unknown; outputs?: Record<string, unknown>; status?: string }>;
31
31
  item?: unknown;
32
+ args?: unknown;
32
33
  index?: number;
33
34
  env?: Record<string, string>;
34
35
  output?: unknown;
@@ -295,6 +296,7 @@ export class ExpressionEvaluator {
295
296
  secrets: context.secrets || {},
296
297
  steps: context.steps || {},
297
298
  item: context.item,
299
+ args: context.args,
298
300
  index: context.index,
299
301
  env: context.env || {},
300
302
  stdout: contextAsRecord.stdout, // For transform expressions
@@ -95,6 +95,9 @@ const LlmStepSchema = BaseStepSchema.extend({
95
95
  ])
96
96
  )
97
97
  .optional(),
98
+ useStandardTools: z.boolean().optional(),
99
+ allowOutsideCwd: z.boolean().optional(),
100
+ allowInsecure: z.boolean().optional(),
98
101
  });
99
102
 
100
103
  const WorkflowStepSchema = BaseStepSchema.extend({