keystone-cli 0.8.0 → 1.0.1
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 +486 -54
- package/package.json +8 -2
- package/src/__fixtures__/index.ts +100 -0
- package/src/cli.ts +809 -90
- package/src/db/memory-db.ts +35 -1
- package/src/db/workflow-db.test.ts +24 -0
- package/src/db/workflow-db.ts +469 -14
- package/src/expression/evaluator.ts +68 -4
- package/src/parser/agent-parser.ts +6 -3
- package/src/parser/config-schema.ts +38 -2
- package/src/parser/schema.ts +192 -7
- package/src/parser/test-schema.ts +29 -0
- package/src/parser/workflow-parser.test.ts +54 -0
- package/src/parser/workflow-parser.ts +153 -7
- package/src/runner/aggregate-error.test.ts +57 -0
- package/src/runner/aggregate-error.ts +46 -0
- package/src/runner/audit-verification.test.ts +2 -2
- package/src/runner/auto-heal.test.ts +1 -1
- package/src/runner/blueprint-executor.test.ts +63 -0
- package/src/runner/blueprint-executor.ts +157 -0
- package/src/runner/concurrency-limit.test.ts +82 -0
- package/src/runner/debug-repl.ts +18 -3
- package/src/runner/durable-timers.test.ts +200 -0
- package/src/runner/engine-executor.test.ts +464 -0
- package/src/runner/engine-executor.ts +489 -0
- package/src/runner/foreach-executor.ts +30 -12
- package/src/runner/llm-adapter.test.ts +282 -5
- package/src/runner/llm-adapter.ts +581 -8
- package/src/runner/llm-clarification.test.ts +79 -21
- package/src/runner/llm-errors.ts +83 -0
- package/src/runner/llm-executor.test.ts +258 -219
- package/src/runner/llm-executor.ts +226 -29
- package/src/runner/mcp-client.ts +70 -3
- package/src/runner/mcp-manager.test.ts +52 -52
- package/src/runner/mcp-manager.ts +12 -5
- package/src/runner/mcp-server.test.ts +117 -78
- package/src/runner/mcp-server.ts +13 -4
- package/src/runner/optimization-runner.ts +48 -31
- package/src/runner/reflexion.test.ts +1 -1
- package/src/runner/resource-pool.test.ts +113 -0
- package/src/runner/resource-pool.ts +164 -0
- package/src/runner/shell-executor.ts +130 -32
- package/src/runner/standard-tools-integration.test.ts +36 -36
- package/src/runner/standard-tools.test.ts +18 -0
- package/src/runner/standard-tools.ts +110 -37
- package/src/runner/step-executor.test.ts +176 -16
- package/src/runner/step-executor.ts +530 -86
- package/src/runner/stream-utils.test.ts +14 -0
- package/src/runner/subflow-outputs.test.ts +103 -0
- package/src/runner/test-harness.ts +161 -0
- package/src/runner/tool-integration.test.ts +73 -79
- package/src/runner/workflow-runner.test.ts +492 -15
- package/src/runner/workflow-runner.ts +1438 -79
- package/src/runner/workflow-subflows.test.ts +255 -0
- package/src/templates/agents/keystone-architect.md +19 -14
- package/src/templates/agents/tester.md +21 -0
- package/src/templates/batch-processor.yaml +1 -1
- package/src/templates/child-rollback.yaml +11 -0
- package/src/templates/decompose-implement.yaml +53 -0
- package/src/templates/decompose-problem.yaml +159 -0
- package/src/templates/decompose-research.yaml +52 -0
- package/src/templates/decompose-review.yaml +51 -0
- package/src/templates/dev.yaml +134 -0
- package/src/templates/engine-example.yaml +33 -0
- package/src/templates/fan-out-fan-in.yaml +61 -0
- package/src/templates/loop-parallel.yaml +1 -1
- package/src/templates/memory-service.yaml +1 -1
- package/src/templates/parent-rollback.yaml +16 -0
- package/src/templates/robust-automation.yaml +1 -1
- package/src/templates/scaffold-feature.yaml +29 -27
- package/src/templates/scaffold-generate.yaml +41 -0
- package/src/templates/scaffold-plan.yaml +53 -0
- package/src/types/status.ts +3 -0
- package/src/ui/dashboard.tsx +4 -3
- package/src/utils/assets.macro.ts +36 -0
- package/src/utils/auth-manager.ts +585 -8
- package/src/utils/blueprint-utils.test.ts +49 -0
- package/src/utils/blueprint-utils.ts +80 -0
- package/src/utils/circuit-breaker.test.ts +177 -0
- package/src/utils/circuit-breaker.ts +160 -0
- package/src/utils/config-loader.test.ts +100 -13
- package/src/utils/config-loader.ts +44 -17
- package/src/utils/constants.ts +62 -0
- package/src/utils/error-renderer.test.ts +267 -0
- package/src/utils/error-renderer.ts +320 -0
- package/src/utils/json-parser.test.ts +4 -0
- package/src/utils/json-parser.ts +18 -1
- package/src/utils/mermaid.ts +4 -0
- package/src/utils/paths.test.ts +46 -0
- package/src/utils/paths.ts +70 -0
- package/src/utils/process-sandbox.test.ts +128 -0
- package/src/utils/process-sandbox.ts +293 -0
- package/src/utils/rate-limiter.test.ts +143 -0
- package/src/utils/rate-limiter.ts +221 -0
- package/src/utils/redactor.test.ts +23 -15
- package/src/utils/redactor.ts +65 -25
- package/src/utils/resource-loader.test.ts +54 -0
- package/src/utils/resource-loader.ts +158 -0
- package/src/utils/sandbox.test.ts +69 -4
- package/src/utils/sandbox.ts +69 -6
- package/src/utils/schema-validator.ts +65 -0
- package/src/utils/workflow-registry.test.ts +57 -0
- package/src/utils/workflow-registry.ts +45 -25
- /package/src/expression/{evaluator.audit.test.ts → evaluator-audit.test.ts} +0 -0
- /package/src/runner/{mcp-client.audit.test.ts → mcp-client-audit.test.ts} +0 -0
package/src/cli.ts
CHANGED
|
@@ -1,17 +1,32 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import { Command } from 'commander';
|
|
5
5
|
|
|
6
6
|
import exploreAgent from './templates/agents/explore.md' with { type: 'text' };
|
|
7
7
|
import generalAgent from './templates/agents/general.md' with { type: 'text' };
|
|
8
8
|
import architectAgent from './templates/agents/keystone-architect.md' with { type: 'text' };
|
|
9
|
+
import softwareEngineerAgent from './templates/agents/software-engineer.md' with { type: 'text' };
|
|
10
|
+
import summarizerAgent from './templates/agents/summarizer.md' with { type: 'text' };
|
|
11
|
+
import testerAgent from './templates/agents/tester.md' with { type: 'text' };
|
|
12
|
+
import decomposeImplementWorkflow from './templates/decompose-implement.yaml' with { type: 'text' };
|
|
13
|
+
import decomposeWorkflow from './templates/decompose-problem.yaml' with { type: 'text' };
|
|
14
|
+
import decomposeResearchWorkflow from './templates/decompose-research.yaml' with { type: 'text' };
|
|
15
|
+
import decomposeReviewWorkflow from './templates/decompose-review.yaml' with { type: 'text' };
|
|
16
|
+
import devWorkflow from './templates/dev.yaml' with { type: 'text' };
|
|
9
17
|
// Default templates
|
|
10
18
|
import scaffoldWorkflow from './templates/scaffold-feature.yaml' with { type: 'text' };
|
|
19
|
+
import scaffoldGenerateWorkflow from './templates/scaffold-generate.yaml' with { type: 'text' };
|
|
20
|
+
import scaffoldPlanWorkflow from './templates/scaffold-plan.yaml' with { type: 'text' };
|
|
11
21
|
|
|
22
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
12
23
|
import { WorkflowDb, type WorkflowRun } from './db/workflow-db.ts';
|
|
24
|
+
import type { TestDefinition } from './parser/test-schema.ts';
|
|
13
25
|
import { WorkflowParser } from './parser/workflow-parser.ts';
|
|
26
|
+
import { WorkflowSuspendedError, WorkflowWaitingError } from './runner/step-executor.ts';
|
|
27
|
+
import { TestHarness } from './runner/test-harness.ts';
|
|
14
28
|
import { ConfigLoader } from './utils/config-loader.ts';
|
|
29
|
+
import { LIMITS } from './utils/constants.ts';
|
|
15
30
|
import { ConsoleLogger } from './utils/logger.ts';
|
|
16
31
|
import { generateMermaidGraph, renderWorkflowAsAscii } from './utils/mermaid.ts';
|
|
17
32
|
import { WorkflowRegistry } from './utils/workflow-registry.ts';
|
|
@@ -19,12 +34,85 @@ import { WorkflowRegistry } from './utils/workflow-registry.ts';
|
|
|
19
34
|
import pkg from '../package.json' with { type: 'json' };
|
|
20
35
|
|
|
21
36
|
const program = new Command();
|
|
37
|
+
const defaultRetentionDays = ConfigLoader.load().storage?.retention_days ?? 30;
|
|
38
|
+
const MAX_INPUT_STRING_LENGTH = LIMITS.MAX_INPUT_STRING_LENGTH;
|
|
22
39
|
|
|
23
40
|
program
|
|
24
41
|
.name('keystone')
|
|
25
42
|
.description('A local-first, declarative, agentic workflow orchestrator')
|
|
26
43
|
.version(pkg.version);
|
|
27
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Parse CLI input pairs (key=value) into a record.
|
|
47
|
+
* Attempts JSON parsing for complex types, falls back to string for simple values.
|
|
48
|
+
*
|
|
49
|
+
* @param pairs Array of key=value strings
|
|
50
|
+
* @returns Record of parsed inputs
|
|
51
|
+
*/
|
|
52
|
+
const parseInputs = (pairs?: string[]): Record<string, unknown> => {
|
|
53
|
+
const inputs: Record<string, unknown> = Object.create(null);
|
|
54
|
+
const blockedKeys = new Set(['__proto__', 'prototype', 'constructor']);
|
|
55
|
+
if (!pairs) return inputs;
|
|
56
|
+
for (const pair of pairs) {
|
|
57
|
+
const index = pair.indexOf('=');
|
|
58
|
+
if (index <= 0) {
|
|
59
|
+
console.warn(`⚠️ Invalid input format: "${pair}" (expected key=value)`);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const key = pair.slice(0, index);
|
|
63
|
+
const value = pair.slice(index + 1);
|
|
64
|
+
|
|
65
|
+
// Validate key format (no special characters that could cause issues)
|
|
66
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
|
|
67
|
+
console.warn(`⚠️ Invalid input key: "${key}" (use alphanumeric and underscores only)`);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (blockedKeys.has(key)) {
|
|
71
|
+
console.warn(`⚠️ Invalid input key: "${key}" (reserved keyword)`);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
// Attempt JSON parse for objects, arrays, booleans, numbers
|
|
77
|
+
const parsed = JSON.parse(value);
|
|
78
|
+
if (typeof parsed === 'string') {
|
|
79
|
+
if (parsed.length > MAX_INPUT_STRING_LENGTH) {
|
|
80
|
+
console.warn(
|
|
81
|
+
`⚠️ Input "${key}" exceeds maximum length of ${MAX_INPUT_STRING_LENGTH} characters`
|
|
82
|
+
);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (parsed.includes('\u0000')) {
|
|
86
|
+
console.warn(`⚠️ Input "${key}" contains invalid null characters`);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
inputs[key] = parsed;
|
|
91
|
+
} catch {
|
|
92
|
+
if (value.length > MAX_INPUT_STRING_LENGTH) {
|
|
93
|
+
console.warn(
|
|
94
|
+
`⚠️ Input "${key}" exceeds maximum length of ${MAX_INPUT_STRING_LENGTH} characters`
|
|
95
|
+
);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (value.includes('\u0000')) {
|
|
99
|
+
console.warn(`⚠️ Input "${key}" contains invalid null characters`);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
// Check if it looks like malformed JSON (starts with { or [)
|
|
103
|
+
if ((value.startsWith('{') || value.startsWith('[')) && value.length > 1) {
|
|
104
|
+
console.warn(
|
|
105
|
+
`⚠️ Input "${key}" looks like JSON but failed to parse. Check for syntax errors.`
|
|
106
|
+
);
|
|
107
|
+
console.warn(` Value: ${value.slice(0, 50)}${value.length > 50 ? '...' : ''}`);
|
|
108
|
+
}
|
|
109
|
+
// Fall back to string value
|
|
110
|
+
inputs[key] = value;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return inputs;
|
|
114
|
+
};
|
|
115
|
+
|
|
28
116
|
// ===== keystone init =====
|
|
29
117
|
program
|
|
30
118
|
.command('init')
|
|
@@ -77,9 +165,15 @@ model_mappings:
|
|
|
77
165
|
# command: npx
|
|
78
166
|
# args: ["-y", "@modelcontextprotocol/server-filesystem", "."]
|
|
79
167
|
|
|
168
|
+
# engines:
|
|
169
|
+
# allowlist:
|
|
170
|
+
# codex:
|
|
171
|
+
# command: codex
|
|
172
|
+
# version: "1.2.3"
|
|
173
|
+
# versionArgs: ["--version"]
|
|
174
|
+
|
|
80
175
|
storage:
|
|
81
176
|
retention_days: 30
|
|
82
|
-
workflows_directory: workflows
|
|
83
177
|
`;
|
|
84
178
|
writeFileSync(configPath, defaultConfig);
|
|
85
179
|
console.log(`✓ Created ${configPath}`);
|
|
@@ -106,6 +200,30 @@ workflows_directory: workflows
|
|
|
106
200
|
path: '.keystone/workflows/scaffold-feature.yaml',
|
|
107
201
|
content: scaffoldWorkflow,
|
|
108
202
|
},
|
|
203
|
+
{
|
|
204
|
+
path: '.keystone/workflows/scaffold-plan.yaml',
|
|
205
|
+
content: scaffoldPlanWorkflow,
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
path: '.keystone/workflows/scaffold-generate.yaml',
|
|
209
|
+
content: scaffoldGenerateWorkflow,
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
path: '.keystone/workflows/decompose-problem.yaml',
|
|
213
|
+
content: decomposeWorkflow,
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
path: '.keystone/workflows/decompose-research.yaml',
|
|
217
|
+
content: decomposeResearchWorkflow,
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
path: '.keystone/workflows/decompose-implement.yaml',
|
|
221
|
+
content: decomposeImplementWorkflow,
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
path: '.keystone/workflows/decompose-review.yaml',
|
|
225
|
+
content: decomposeReviewWorkflow,
|
|
226
|
+
},
|
|
109
227
|
{
|
|
110
228
|
path: '.keystone/workflows/agents/keystone-architect.md',
|
|
111
229
|
content: architectAgent,
|
|
@@ -118,6 +236,22 @@ workflows_directory: workflows
|
|
|
118
236
|
path: '.keystone/workflows/agents/explore.md',
|
|
119
237
|
content: exploreAgent,
|
|
120
238
|
},
|
|
239
|
+
{
|
|
240
|
+
path: '.keystone/workflows/agents/software-engineer.md',
|
|
241
|
+
content: softwareEngineerAgent,
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
path: '.keystone/workflows/agents/summarizer.md',
|
|
245
|
+
content: summarizerAgent,
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
path: '.keystone/workflows/dev.yaml',
|
|
249
|
+
content: devWorkflow,
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
path: '.keystone/workflows/agents/tester.md',
|
|
253
|
+
content: testerAgent,
|
|
254
|
+
},
|
|
121
255
|
];
|
|
122
256
|
|
|
123
257
|
for (const seed of seeds) {
|
|
@@ -141,7 +275,9 @@ program
|
|
|
141
275
|
.command('validate')
|
|
142
276
|
.description('Validate workflow files')
|
|
143
277
|
.argument('[path]', 'Workflow file or directory to validate (default: .keystone/workflows/)')
|
|
144
|
-
.
|
|
278
|
+
.option('--strict', 'Enable strict validation (schemas, enums)')
|
|
279
|
+
.option('--explain', 'Show detailed error context with suggestions')
|
|
280
|
+
.action(async (pathArg, options) => {
|
|
145
281
|
const path = pathArg || '.keystone/workflows/';
|
|
146
282
|
|
|
147
283
|
try {
|
|
@@ -176,12 +312,35 @@ program
|
|
|
176
312
|
for (const file of files) {
|
|
177
313
|
try {
|
|
178
314
|
const workflow = WorkflowParser.loadWorkflow(file);
|
|
315
|
+
if (options.strict) {
|
|
316
|
+
const source = readFileSync(file, 'utf-8');
|
|
317
|
+
WorkflowParser.validateStrict(workflow, source);
|
|
318
|
+
}
|
|
179
319
|
console.log(` ✓ ${file.padEnd(40)} ${workflow.name} (${workflow.steps.length} steps)`);
|
|
180
320
|
successCount++;
|
|
181
321
|
} catch (error) {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
322
|
+
if (options.explain) {
|
|
323
|
+
const { readFileSync } = await import('node:fs');
|
|
324
|
+
const { formatYamlError, renderError, formatError } = await import(
|
|
325
|
+
'./utils/error-renderer.ts'
|
|
326
|
+
);
|
|
327
|
+
try {
|
|
328
|
+
const source = readFileSync(file, 'utf-8');
|
|
329
|
+
const formatted = formatYamlError(error as Error, source, file);
|
|
330
|
+
console.error(renderError({ message: formatted.summary, source, filePath: file }));
|
|
331
|
+
} catch {
|
|
332
|
+
console.error(
|
|
333
|
+
renderError({
|
|
334
|
+
message: error instanceof Error ? error.message : String(error),
|
|
335
|
+
filePath: file,
|
|
336
|
+
})
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
} else {
|
|
340
|
+
console.error(
|
|
341
|
+
` ✗ ${file.padEnd(40)} ${error instanceof Error ? error.message : String(error)}`
|
|
342
|
+
);
|
|
343
|
+
}
|
|
185
344
|
failCount++;
|
|
186
345
|
}
|
|
187
346
|
}
|
|
@@ -228,29 +387,16 @@ program
|
|
|
228
387
|
.option('-i, --input <key=value...>', 'Input values')
|
|
229
388
|
.option('--dry-run', 'Show what would be executed without actually running it')
|
|
230
389
|
.option('--debug', 'Enable interactive debug mode on failure')
|
|
390
|
+
.option('--no-dedup', 'Disable idempotency/deduplication')
|
|
231
391
|
.option('--resume', 'Resume the last run of this workflow if it failed or was paused')
|
|
392
|
+
.option('--explain', 'Show detailed error context with suggestions on failure')
|
|
232
393
|
.action(async (workflowPathArg, options) => {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
if (options.input) {
|
|
236
|
-
for (const pair of options.input) {
|
|
237
|
-
const index = pair.indexOf('=');
|
|
238
|
-
if (index > 0) {
|
|
239
|
-
const key = pair.slice(0, index);
|
|
240
|
-
const value = pair.slice(index + 1);
|
|
241
|
-
// Try to parse as JSON, otherwise use as string
|
|
242
|
-
try {
|
|
243
|
-
inputs[key] = JSON.parse(value);
|
|
244
|
-
} catch {
|
|
245
|
-
inputs[key] = value;
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
}
|
|
394
|
+
const inputs = parseInputs(options.input);
|
|
395
|
+
let resolvedPath: string | undefined;
|
|
250
396
|
|
|
251
397
|
// Load and validate workflow
|
|
252
398
|
try {
|
|
253
|
-
|
|
399
|
+
resolvedPath = WorkflowRegistry.resolvePath(workflowPathArg);
|
|
254
400
|
const workflow = WorkflowParser.loadWorkflow(resolvedPath);
|
|
255
401
|
|
|
256
402
|
// Import WorkflowRunner dynamically
|
|
@@ -286,10 +432,12 @@ program
|
|
|
286
432
|
}
|
|
287
433
|
|
|
288
434
|
const runner = new WorkflowRunner(workflow, {
|
|
289
|
-
inputs,
|
|
435
|
+
inputs: resumeRunId ? undefined : inputs,
|
|
436
|
+
resumeInputs: resumeRunId ? inputs : undefined,
|
|
290
437
|
workflowDir: dirname(resolvedPath),
|
|
291
438
|
dryRun: !!options.dryRun,
|
|
292
439
|
debug: !!options.debug,
|
|
440
|
+
dedup: options.dedup,
|
|
293
441
|
resumeRunId,
|
|
294
442
|
logger,
|
|
295
443
|
});
|
|
@@ -302,10 +450,108 @@ program
|
|
|
302
450
|
}
|
|
303
451
|
process.exit(0);
|
|
304
452
|
} catch (error) {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
453
|
+
if (options.explain) {
|
|
454
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
455
|
+
try {
|
|
456
|
+
const { readFileSync } = await import('node:fs');
|
|
457
|
+
const { renderError } = await import('./utils/error-renderer.ts');
|
|
458
|
+
const source = resolvedPath ? readFileSync(resolvedPath, 'utf-8') : undefined;
|
|
459
|
+
console.error(
|
|
460
|
+
renderError({
|
|
461
|
+
message,
|
|
462
|
+
source,
|
|
463
|
+
filePath: resolvedPath,
|
|
464
|
+
})
|
|
465
|
+
);
|
|
466
|
+
} catch {
|
|
467
|
+
console.error('✗ Failed to execute workflow:', message);
|
|
468
|
+
}
|
|
469
|
+
} else {
|
|
470
|
+
console.error(
|
|
471
|
+
'✗ Failed to execute workflow:',
|
|
472
|
+
error instanceof Error ? error.message : error
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
process.exit(1);
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
// ===== keystone test =====
|
|
480
|
+
program
|
|
481
|
+
.command('test')
|
|
482
|
+
.description('Run workflow tests with fixtures and snapshots')
|
|
483
|
+
.argument('[path]', 'Test file or directory to run (default: .keystone/tests/)')
|
|
484
|
+
.option('-u, --update', 'Update snapshots on mismatch or failure')
|
|
485
|
+
.action(async (pathArg, options) => {
|
|
486
|
+
const testPath = pathArg || '.keystone/tests/';
|
|
487
|
+
|
|
488
|
+
try {
|
|
489
|
+
let files: string[] = [];
|
|
490
|
+
if (existsSync(testPath) && (testPath.endsWith('.yaml') || testPath.endsWith('.yml'))) {
|
|
491
|
+
files = [testPath];
|
|
492
|
+
} else if (existsSync(testPath)) {
|
|
493
|
+
const glob = new Bun.Glob('**/*.test.{yaml,yml}');
|
|
494
|
+
for await (const file of glob.scan(testPath)) {
|
|
495
|
+
files.push(join(testPath, file));
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (files.length === 0) {
|
|
500
|
+
console.log('⊘ No test files found.');
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
console.log(`🧪 Running ${files.length} test(s)...\n`);
|
|
505
|
+
|
|
506
|
+
let totalPassed = 0;
|
|
507
|
+
let totalFailed = 0;
|
|
508
|
+
|
|
509
|
+
for (const file of files) {
|
|
510
|
+
try {
|
|
511
|
+
const content = readFileSync(file, 'utf-8');
|
|
512
|
+
const testDef = parseYaml(content) as TestDefinition;
|
|
513
|
+
|
|
514
|
+
console.log(` ▶ ${testDef.name} (${file})`);
|
|
515
|
+
|
|
516
|
+
const workflowPath = WorkflowRegistry.resolvePath(testDef.workflow);
|
|
517
|
+
const workflow = WorkflowParser.loadWorkflow(workflowPath);
|
|
518
|
+
|
|
519
|
+
const harness = new TestHarness(workflow, testDef.fixture);
|
|
520
|
+
const result = await harness.run();
|
|
521
|
+
|
|
522
|
+
if (!testDef.snapshot || options.update) {
|
|
523
|
+
testDef.snapshot = result;
|
|
524
|
+
writeFileSync(file, stringifyYaml(testDef));
|
|
525
|
+
console.log(` ✓ Snapshot ${options.update ? 'updated' : 'initialized'}`);
|
|
526
|
+
totalPassed++;
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Compare snapshot (simple JSON stringify for now)
|
|
531
|
+
const expected = JSON.stringify(testDef.snapshot);
|
|
532
|
+
const actual = JSON.stringify(result);
|
|
533
|
+
|
|
534
|
+
if (expected !== actual) {
|
|
535
|
+
console.error(` ✗ Snapshot mismatch in ${file}`);
|
|
536
|
+
totalFailed++;
|
|
537
|
+
} else {
|
|
538
|
+
console.log(' ✓ Passed');
|
|
539
|
+
totalPassed++;
|
|
540
|
+
}
|
|
541
|
+
} catch (error) {
|
|
542
|
+
console.error(
|
|
543
|
+
` ✗ Test failed: ${error instanceof Error ? error.message : String(error)}`
|
|
544
|
+
);
|
|
545
|
+
totalFailed++;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
console.log(`\nSummary: ${totalPassed} passed, ${totalFailed} failed.`);
|
|
550
|
+
if (totalFailed > 0) {
|
|
551
|
+
process.exit(1);
|
|
552
|
+
}
|
|
553
|
+
} catch (error) {
|
|
554
|
+
console.error('✗ Test execution failed:', error instanceof Error ? error.message : error);
|
|
309
555
|
process.exit(1);
|
|
310
556
|
}
|
|
311
557
|
});
|
|
@@ -345,22 +591,7 @@ program
|
|
|
345
591
|
const resolvedPath = WorkflowRegistry.resolvePath(workflowPath);
|
|
346
592
|
const workflow = WorkflowParser.loadWorkflow(resolvedPath);
|
|
347
593
|
|
|
348
|
-
|
|
349
|
-
const inputs: Record<string, unknown> = {};
|
|
350
|
-
if (options.input) {
|
|
351
|
-
for (const pair of options.input) {
|
|
352
|
-
const index = pair.indexOf('=');
|
|
353
|
-
if (index > 0) {
|
|
354
|
-
const key = pair.slice(0, index);
|
|
355
|
-
const value = pair.slice(index + 1);
|
|
356
|
-
try {
|
|
357
|
-
inputs[key] = JSON.parse(value);
|
|
358
|
-
} catch {
|
|
359
|
-
inputs[key] = value;
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
}
|
|
594
|
+
const inputs = parseInputs(options.input);
|
|
364
595
|
|
|
365
596
|
const runner = new OptimizationRunner(workflow, {
|
|
366
597
|
workflowPath: resolvedPath,
|
|
@@ -390,6 +621,7 @@ program
|
|
|
390
621
|
.description('Resume a paused or failed workflow run')
|
|
391
622
|
.argument('<run_id>', 'Run ID to resume')
|
|
392
623
|
.option('-w, --workflow <path>', 'Path to workflow file (auto-detected if not specified)')
|
|
624
|
+
.option('-i, --input <key=value...>', 'Input values for resume')
|
|
393
625
|
.action(async (runId, options) => {
|
|
394
626
|
try {
|
|
395
627
|
const db = new WorkflowDb();
|
|
@@ -431,8 +663,10 @@ program
|
|
|
431
663
|
// Import WorkflowRunner dynamically
|
|
432
664
|
const { WorkflowRunner } = await import('./runner/workflow-runner.ts');
|
|
433
665
|
const logger = new ConsoleLogger();
|
|
666
|
+
const inputs = parseInputs(options.input);
|
|
434
667
|
const runner = new WorkflowRunner(workflow, {
|
|
435
668
|
resumeRunId: runId,
|
|
669
|
+
resumeInputs: inputs,
|
|
436
670
|
workflowDir: dirname(workflowPath),
|
|
437
671
|
logger,
|
|
438
672
|
});
|
|
@@ -531,6 +765,124 @@ program
|
|
|
531
765
|
}
|
|
532
766
|
});
|
|
533
767
|
|
|
768
|
+
// ===== keystone compile =====
|
|
769
|
+
program
|
|
770
|
+
.command('compile')
|
|
771
|
+
.description('Compile a project into a single executable with embedded assets')
|
|
772
|
+
.option('-o, --outfile <path>', 'Output executable path', 'keystone-app')
|
|
773
|
+
.option('--project <path>', 'Project directory (default: .)', '.')
|
|
774
|
+
.action(async (options) => {
|
|
775
|
+
const { spawnSync } = await import('node:child_process');
|
|
776
|
+
const { resolve, join } = await import('node:path');
|
|
777
|
+
const { existsSync } = await import('node:fs');
|
|
778
|
+
|
|
779
|
+
const projectDir = resolve(options.project);
|
|
780
|
+
const keystoneDir = join(projectDir, '.keystone');
|
|
781
|
+
|
|
782
|
+
if (!existsSync(keystoneDir)) {
|
|
783
|
+
console.error(`✗ No .keystone directory found at ${projectDir}`);
|
|
784
|
+
process.exit(1);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
console.log(`🏗️ Compiling project at ${projectDir}...`);
|
|
788
|
+
console.log(`📂 Embedding assets from ${keystoneDir}`);
|
|
789
|
+
|
|
790
|
+
// Find the CLI source path
|
|
791
|
+
const cliSource = resolve(import.meta.dir, 'cli.ts');
|
|
792
|
+
|
|
793
|
+
const buildArgs = ['build', cliSource, '--compile', '--outfile', options.outfile];
|
|
794
|
+
|
|
795
|
+
console.log(`🚀 Running: ASSETS_DIR=${keystoneDir} bun ${buildArgs.join(' ')}`);
|
|
796
|
+
|
|
797
|
+
const result = spawnSync('bun', buildArgs, {
|
|
798
|
+
env: {
|
|
799
|
+
...process.env,
|
|
800
|
+
ASSETS_DIR: keystoneDir,
|
|
801
|
+
},
|
|
802
|
+
stdio: 'inherit',
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
if (result.status === 0) {
|
|
806
|
+
console.log(`\n✨ Successfully compiled to ${options.outfile}`);
|
|
807
|
+
console.log(` You can now run ./${options.outfile} anywhere!`);
|
|
808
|
+
} else {
|
|
809
|
+
console.error(`\n✗ Compilation failed with exit code ${result.status}`);
|
|
810
|
+
process.exit(1);
|
|
811
|
+
}
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
// ===== keystone dev =====
|
|
815
|
+
program
|
|
816
|
+
.command('dev')
|
|
817
|
+
.description('Run the self-bootstrapping DevMode workflow')
|
|
818
|
+
.argument('<task>', 'The development task to perform')
|
|
819
|
+
.option('--auto-approve', 'Skip the plan approval step', false)
|
|
820
|
+
.action(async (task, options) => {
|
|
821
|
+
try {
|
|
822
|
+
// Find the dev workflow path
|
|
823
|
+
// Priority:
|
|
824
|
+
// 1. Local .keystone/workflows/dev.yaml
|
|
825
|
+
// 2. Embedded resource
|
|
826
|
+
let devPath: string;
|
|
827
|
+
try {
|
|
828
|
+
devPath = WorkflowRegistry.resolvePath('dev');
|
|
829
|
+
} catch {
|
|
830
|
+
// Fallback to searching in templates if not indexed yet
|
|
831
|
+
devPath = join(process.cwd(), '.keystone/workflows/dev.yaml');
|
|
832
|
+
if (!existsSync(devPath)) {
|
|
833
|
+
console.error('✗ Dev workflow not found. Run "keystone init" to seed it.');
|
|
834
|
+
process.exit(1);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
console.log(`🏗️ Starting DevMode for task: ${task}\n`);
|
|
839
|
+
|
|
840
|
+
// Import WorkflowRunner dynamically
|
|
841
|
+
const { WorkflowRunner } = await import('./runner/workflow-runner.ts');
|
|
842
|
+
const { WorkflowParser } = await import('./parser/workflow-parser.ts');
|
|
843
|
+
const logger = new ConsoleLogger();
|
|
844
|
+
|
|
845
|
+
const workflow = WorkflowParser.loadWorkflow(devPath);
|
|
846
|
+
const runner = new WorkflowRunner(workflow, {
|
|
847
|
+
inputs: { task, auto_approve: options.auto_approve },
|
|
848
|
+
workflowDir: dirname(devPath),
|
|
849
|
+
logger,
|
|
850
|
+
allowInsecure: true, // Trusted internal workflow
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
const outputs = await runner.run();
|
|
854
|
+
if (Object.keys(outputs).length > 0) {
|
|
855
|
+
console.log('\nDevMode Summary:');
|
|
856
|
+
console.log(JSON.stringify(runner.redact(outputs), null, 2));
|
|
857
|
+
}
|
|
858
|
+
process.exit(0);
|
|
859
|
+
} catch (error) {
|
|
860
|
+
console.error('\n✗ DevMode failed:', error instanceof Error ? error.message : error);
|
|
861
|
+
process.exit(1);
|
|
862
|
+
}
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
// ===== keystone manifest =====
|
|
866
|
+
program
|
|
867
|
+
.command('manifest')
|
|
868
|
+
.description('Show embedded assets manifest')
|
|
869
|
+
.action(async () => {
|
|
870
|
+
const { ResourceLoader } = await import('./utils/resource-loader.ts');
|
|
871
|
+
const assets = ResourceLoader.getEmbeddedAssets();
|
|
872
|
+
const keys = Object.keys(assets);
|
|
873
|
+
|
|
874
|
+
if (keys.length === 0) {
|
|
875
|
+
console.log('No embedded assets found.');
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
console.log(`\n📦 Embedded Assets (${keys.length}):`);
|
|
880
|
+
for (const key of keys.sort()) {
|
|
881
|
+
console.log(` - ${key} (${assets[key].length} bytes)`);
|
|
882
|
+
}
|
|
883
|
+
console.log('');
|
|
884
|
+
});
|
|
885
|
+
|
|
534
886
|
async function showRunLogs(run: WorkflowRun, db: WorkflowDb, verbose: boolean) {
|
|
535
887
|
console.log(`\n🏛️ Run: ${run.workflow_name} (${run.id})`);
|
|
536
888
|
console.log(` Status: ${run.status}`);
|
|
@@ -609,7 +961,7 @@ async function performMaintenance(days: number) {
|
|
|
609
961
|
program
|
|
610
962
|
.command('prune')
|
|
611
963
|
.description('Delete old workflow runs from the database (alias for maintenance)')
|
|
612
|
-
.option('--days <number>', 'Days to keep',
|
|
964
|
+
.option('--days <number>', 'Days to keep', String(defaultRetentionDays))
|
|
613
965
|
.action(async (options) => {
|
|
614
966
|
const days = Number.parseInt(options.days, 10);
|
|
615
967
|
await performMaintenance(days);
|
|
@@ -618,12 +970,236 @@ program
|
|
|
618
970
|
program
|
|
619
971
|
.command('maintenance')
|
|
620
972
|
.description('Perform database maintenance (prune old runs and vacuum)')
|
|
621
|
-
.option('--days <days>', 'Delete runs older than this many days',
|
|
973
|
+
.option('--days <days>', 'Delete runs older than this many days', String(defaultRetentionDays))
|
|
622
974
|
.action(async (options) => {
|
|
623
975
|
const days = Number.parseInt(options.days, 10);
|
|
624
976
|
await performMaintenance(days);
|
|
625
977
|
});
|
|
626
978
|
|
|
979
|
+
// ===== keystone dedup =====
|
|
980
|
+
const dedup = program.command('dedup').description('Manage idempotency/deduplication records');
|
|
981
|
+
|
|
982
|
+
dedup
|
|
983
|
+
.command('list')
|
|
984
|
+
.description('List idempotency records')
|
|
985
|
+
.argument('[run_id]', 'Filter by run ID (optional)')
|
|
986
|
+
.action(async (runId) => {
|
|
987
|
+
try {
|
|
988
|
+
const db = new WorkflowDb();
|
|
989
|
+
const records = await db.listIdempotencyRecords(runId);
|
|
990
|
+
db.close();
|
|
991
|
+
|
|
992
|
+
if (records.length === 0) {
|
|
993
|
+
console.log('No idempotency records found.');
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
console.log('\n🔑 Idempotency Records:');
|
|
998
|
+
console.log(''.padEnd(100, '-'));
|
|
999
|
+
console.log(
|
|
1000
|
+
`${'Key'.padEnd(30)} ${'Step'.padEnd(15)} ${'Status'.padEnd(10)} ${'Created At'}`
|
|
1001
|
+
);
|
|
1002
|
+
console.log(''.padEnd(100, '-'));
|
|
1003
|
+
|
|
1004
|
+
for (const record of records) {
|
|
1005
|
+
const key = record.idempotency_key.slice(0, 28);
|
|
1006
|
+
console.log(
|
|
1007
|
+
`${key.padEnd(30)} ${record.step_id.padEnd(15)} ${record.status.padEnd(10)} ${record.created_at}`
|
|
1008
|
+
);
|
|
1009
|
+
}
|
|
1010
|
+
console.log(`\nTotal: ${records.length} record(s)`);
|
|
1011
|
+
} catch (error) {
|
|
1012
|
+
console.error('✗ Failed to list records:', error instanceof Error ? error.message : error);
|
|
1013
|
+
process.exit(1);
|
|
1014
|
+
}
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
dedup
|
|
1018
|
+
.command('clear')
|
|
1019
|
+
.description('Clear idempotency records')
|
|
1020
|
+
.argument('<target>', 'Run ID to clear, or "--all" to clear all records')
|
|
1021
|
+
.action(async (target) => {
|
|
1022
|
+
try {
|
|
1023
|
+
const db = new WorkflowDb();
|
|
1024
|
+
let count: number;
|
|
1025
|
+
|
|
1026
|
+
if (target === '--all') {
|
|
1027
|
+
count = await db.clearAllIdempotencyRecords();
|
|
1028
|
+
console.log(`✓ Cleared ${count} idempotency record(s)`);
|
|
1029
|
+
} else {
|
|
1030
|
+
count = await db.clearIdempotencyRecords(target);
|
|
1031
|
+
console.log(`✓ Cleared ${count} idempotency record(s) for run ${target.slice(0, 8)}`);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
db.close();
|
|
1035
|
+
} catch (error) {
|
|
1036
|
+
console.error('✗ Failed to clear records:', error instanceof Error ? error.message : error);
|
|
1037
|
+
process.exit(1);
|
|
1038
|
+
}
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
dedup
|
|
1042
|
+
.command('prune')
|
|
1043
|
+
.description('Remove expired idempotency records')
|
|
1044
|
+
.action(async () => {
|
|
1045
|
+
try {
|
|
1046
|
+
const db = new WorkflowDb();
|
|
1047
|
+
const count = await db.pruneIdempotencyRecords();
|
|
1048
|
+
db.close();
|
|
1049
|
+
console.log(`✓ Pruned ${count} expired idempotency record(s)`);
|
|
1050
|
+
} catch (error) {
|
|
1051
|
+
console.error('✗ Failed to prune records:', error instanceof Error ? error.message : error);
|
|
1052
|
+
process.exit(1);
|
|
1053
|
+
}
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
// ===== keystone scheduler =====
|
|
1057
|
+
program
|
|
1058
|
+
.command('scheduler')
|
|
1059
|
+
.description('Run the durable timer scheduler (polls for ready timers)')
|
|
1060
|
+
.option('-i, --interval <seconds>', 'Poll interval in seconds', '30')
|
|
1061
|
+
.option('--once', "Run once and exit (don't poll)")
|
|
1062
|
+
.action(async (options) => {
|
|
1063
|
+
const interval = Number.parseInt(options.interval, 10) * 1000;
|
|
1064
|
+
const db = new WorkflowDb();
|
|
1065
|
+
|
|
1066
|
+
console.log('🏛️ Keystone Durable Timer Scheduler');
|
|
1067
|
+
console.log(`📡 Polling every ${options.interval}s for ready timers...`);
|
|
1068
|
+
|
|
1069
|
+
const poll = async () => {
|
|
1070
|
+
try {
|
|
1071
|
+
const pending = await db.getPendingTimers(undefined, 'sleep');
|
|
1072
|
+
if (pending.length > 0) {
|
|
1073
|
+
console.log(`\n⏰ Found ${pending.length} ready timer(s)`);
|
|
1074
|
+
|
|
1075
|
+
for (const timer of pending) {
|
|
1076
|
+
console.log(` - Resuming run ${timer.run_id.slice(0, 8)} (step: ${timer.step_id})`);
|
|
1077
|
+
|
|
1078
|
+
// Load run to get workflow name
|
|
1079
|
+
const run = await db.getRun(timer.run_id);
|
|
1080
|
+
if (!run) {
|
|
1081
|
+
console.warn(` ⚠️ Run ${timer.run_id} not found in DB`);
|
|
1082
|
+
continue;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
try {
|
|
1086
|
+
const workflowPath = WorkflowRegistry.resolvePath(run.workflow_name);
|
|
1087
|
+
const workflow = WorkflowParser.loadWorkflow(workflowPath);
|
|
1088
|
+
|
|
1089
|
+
const { WorkflowRunner } = await import('./runner/workflow-runner.ts');
|
|
1090
|
+
const runner = new WorkflowRunner(workflow, {
|
|
1091
|
+
resumeRunId: timer.run_id,
|
|
1092
|
+
workflowDir: dirname(workflowPath),
|
|
1093
|
+
logger: new ConsoleLogger(),
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
// Running this in current process iteration
|
|
1097
|
+
// The runner will handle checking the timer status in restoreState
|
|
1098
|
+
await runner.run();
|
|
1099
|
+
console.log(` ✓ Run ${timer.run_id.slice(0, 8)} resumed and finished/paused`);
|
|
1100
|
+
} catch (err) {
|
|
1101
|
+
if (err instanceof WorkflowWaitingError || err instanceof WorkflowSuspendedError) {
|
|
1102
|
+
// This is expected if it hits another wait/human step
|
|
1103
|
+
console.log(` ⏸ Run ${timer.run_id.slice(0, 8)} paused/waiting again`);
|
|
1104
|
+
} else {
|
|
1105
|
+
console.error(
|
|
1106
|
+
` ✗ Failed to resume run ${timer.run_id.slice(0, 8)}:`,
|
|
1107
|
+
err instanceof Error ? err.message : String(err)
|
|
1108
|
+
);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
} catch (err) {
|
|
1114
|
+
console.error('✗ Scheduler error:', err instanceof Error ? err.message : String(err));
|
|
1115
|
+
}
|
|
1116
|
+
};
|
|
1117
|
+
|
|
1118
|
+
if (options.once) {
|
|
1119
|
+
await poll();
|
|
1120
|
+
db.close();
|
|
1121
|
+
process.exit(0);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// Polling loop
|
|
1125
|
+
await poll();
|
|
1126
|
+
setInterval(poll, interval);
|
|
1127
|
+
|
|
1128
|
+
// Keep process alive
|
|
1129
|
+
process.on('SIGINT', () => {
|
|
1130
|
+
console.log('\n👋 Scheduler stopping...');
|
|
1131
|
+
db.close();
|
|
1132
|
+
process.exit(0);
|
|
1133
|
+
});
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
// ===== keystone timers =====
|
|
1137
|
+
const timersCmd = program.command('timers').description('Manage durable timers');
|
|
1138
|
+
|
|
1139
|
+
timersCmd
|
|
1140
|
+
.command('list')
|
|
1141
|
+
.description('List pending timers')
|
|
1142
|
+
.option('-r, --run <run_id>', 'Filter by run ID')
|
|
1143
|
+
.action(async (options) => {
|
|
1144
|
+
try {
|
|
1145
|
+
const db = new WorkflowDb();
|
|
1146
|
+
const timers = await db.listTimers(options.run);
|
|
1147
|
+
db.close();
|
|
1148
|
+
|
|
1149
|
+
if (timers.length === 0) {
|
|
1150
|
+
console.log('No durable timers found.');
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
console.log('\n⏰ Durable Timers:');
|
|
1155
|
+
console.log(''.padEnd(100, '-'));
|
|
1156
|
+
console.log(
|
|
1157
|
+
`${'ID'.padEnd(10)} ${'Run'.padEnd(15)} ${'Step'.padEnd(20)} ${'Type'.padEnd(10)} ${'Wake At'}`
|
|
1158
|
+
);
|
|
1159
|
+
console.log(''.padEnd(100, '-'));
|
|
1160
|
+
|
|
1161
|
+
for (const timer of timers) {
|
|
1162
|
+
const id = timer.id.slice(0, 8);
|
|
1163
|
+
const run = timer.run_id.slice(0, 8);
|
|
1164
|
+
const wakeAt = timer.wake_at ? new Date(timer.wake_at).toLocaleString() : 'N/A';
|
|
1165
|
+
const statusStr = timer.completed_at
|
|
1166
|
+
? ` (DONE at ${new Date(timer.completed_at).toLocaleTimeString()})`
|
|
1167
|
+
: '';
|
|
1168
|
+
|
|
1169
|
+
console.log(
|
|
1170
|
+
`${id.padEnd(10)} ${run.padEnd(15)} ${timer.step_id.padEnd(20)} ${timer.timer_type.padEnd(
|
|
1171
|
+
10
|
|
1172
|
+
)} ${wakeAt}${statusStr}`
|
|
1173
|
+
);
|
|
1174
|
+
}
|
|
1175
|
+
console.log(`\nTotal: ${timers.length} timer(s)`);
|
|
1176
|
+
} catch (error) {
|
|
1177
|
+
console.error('✗ Failed to list timers:', error instanceof Error ? error.message : error);
|
|
1178
|
+
process.exit(1);
|
|
1179
|
+
}
|
|
1180
|
+
});
|
|
1181
|
+
|
|
1182
|
+
timersCmd
|
|
1183
|
+
.command('clear')
|
|
1184
|
+
.description('Clear pending timers')
|
|
1185
|
+
.option('-r, --run <run_id>', 'Clear timers for a specific run')
|
|
1186
|
+
.option('--all', 'Clear all timers')
|
|
1187
|
+
.action(async (options) => {
|
|
1188
|
+
try {
|
|
1189
|
+
if (!options.all && !options.run) {
|
|
1190
|
+
console.error('✗ Please specify --run <id> or --all');
|
|
1191
|
+
process.exit(1);
|
|
1192
|
+
}
|
|
1193
|
+
const db = new WorkflowDb();
|
|
1194
|
+
const count = await db.clearTimers(options.run);
|
|
1195
|
+
db.close();
|
|
1196
|
+
console.log(`✓ Cleared ${count} timer(s)`);
|
|
1197
|
+
} catch (error) {
|
|
1198
|
+
console.error('✗ Failed to clear timers:', error instanceof Error ? error.message : error);
|
|
1199
|
+
process.exit(1);
|
|
1200
|
+
}
|
|
1201
|
+
});
|
|
1202
|
+
|
|
627
1203
|
// ===== keystone ui =====
|
|
628
1204
|
program
|
|
629
1205
|
.command('ui')
|
|
@@ -737,15 +1313,26 @@ mcp
|
|
|
737
1313
|
});
|
|
738
1314
|
|
|
739
1315
|
// ===== keystone config =====
|
|
740
|
-
program
|
|
741
|
-
|
|
742
|
-
|
|
1316
|
+
const configCmd = program.command('config').description('Configuration management');
|
|
1317
|
+
|
|
1318
|
+
configCmd
|
|
1319
|
+
.command('show')
|
|
1320
|
+
.alias('list')
|
|
1321
|
+
.description('Show current configuration and discovery paths')
|
|
743
1322
|
.action(async () => {
|
|
744
1323
|
const { ConfigLoader } = await import('./utils/config-loader.ts');
|
|
1324
|
+
const { PathResolver } = await import('./utils/paths.ts');
|
|
745
1325
|
try {
|
|
746
1326
|
const config = ConfigLoader.load();
|
|
747
1327
|
console.log('\n🏛️ Keystone Configuration:');
|
|
748
1328
|
console.log(JSON.stringify(config, null, 2));
|
|
1329
|
+
|
|
1330
|
+
console.log('\n🔍 Configuration Search Paths (in precedence order):');
|
|
1331
|
+
const paths = PathResolver.getConfigPaths();
|
|
1332
|
+
for (const [i, p] of paths.entries()) {
|
|
1333
|
+
const exists = existsSync(p) ? '✓' : '⊘';
|
|
1334
|
+
console.log(` ${i + 1}. ${exists} ${p}`);
|
|
1335
|
+
}
|
|
749
1336
|
} catch (error) {
|
|
750
1337
|
console.error('✗ Failed to load config:', error instanceof Error ? error.message : error);
|
|
751
1338
|
}
|
|
@@ -758,16 +1345,13 @@ auth
|
|
|
758
1345
|
.command('login')
|
|
759
1346
|
.description('Login to an authentication provider')
|
|
760
1347
|
.argument('[provider]', 'Authentication provider', 'github')
|
|
761
|
-
.option(
|
|
762
|
-
'-p, --provider <provider>',
|
|
763
|
-
'Authentication provider (deprecated, use positional argument)'
|
|
764
|
-
)
|
|
765
1348
|
.option('-t, --token <token>', 'Personal Access Token (if not using interactive mode)')
|
|
766
|
-
.
|
|
1349
|
+
.option('--project <project_id>', 'Google Cloud project ID (Gemini OAuth)')
|
|
1350
|
+
.action(async (provider, options) => {
|
|
767
1351
|
const { AuthManager } = await import('./utils/auth-manager.ts');
|
|
768
|
-
const
|
|
1352
|
+
const providerName = provider.toLowerCase();
|
|
769
1353
|
|
|
770
|
-
if (
|
|
1354
|
+
if (providerName === 'github') {
|
|
771
1355
|
let token = options.token;
|
|
772
1356
|
|
|
773
1357
|
if (!token) {
|
|
@@ -822,12 +1406,87 @@ auth
|
|
|
822
1406
|
console.error('✗ No token provided.');
|
|
823
1407
|
process.exit(1);
|
|
824
1408
|
}
|
|
825
|
-
} else if (
|
|
1409
|
+
} else if (providerName === 'openai-chatgpt') {
|
|
1410
|
+
try {
|
|
1411
|
+
await AuthManager.loginOpenAIChatGPT();
|
|
1412
|
+
console.log('\n✓ Successfully logged in to OpenAI ChatGPT.');
|
|
1413
|
+
return;
|
|
1414
|
+
} catch (error) {
|
|
1415
|
+
console.error(
|
|
1416
|
+
'\n✗ Failed to login with OpenAI ChatGPT:',
|
|
1417
|
+
error instanceof Error ? error.message : error
|
|
1418
|
+
);
|
|
1419
|
+
process.exit(1);
|
|
1420
|
+
}
|
|
1421
|
+
} else if (providerName === 'anthropic-claude') {
|
|
1422
|
+
try {
|
|
1423
|
+
const { url, verifier } = AuthManager.createAnthropicClaudeAuth();
|
|
1424
|
+
|
|
1425
|
+
console.log('\nTo login with Anthropic Claude (Pro/Max):');
|
|
1426
|
+
console.log('1. Visit the following URL in your browser:');
|
|
1427
|
+
console.log(` ${url}\n`);
|
|
1428
|
+
console.log('2. Copy the authorization code and paste it below:\n');
|
|
1429
|
+
|
|
1430
|
+
try {
|
|
1431
|
+
const { platform } = process;
|
|
1432
|
+
const command =
|
|
1433
|
+
platform === 'win32' ? 'start' : platform === 'darwin' ? 'open' : 'xdg-open';
|
|
1434
|
+
const { spawn } = require('node:child_process');
|
|
1435
|
+
spawn(command, [url]);
|
|
1436
|
+
} catch (e) {
|
|
1437
|
+
// Ignore if we can't open the browser automatically
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
let code = options.token;
|
|
1441
|
+
if (!code) {
|
|
1442
|
+
const prompt = 'Authorization Code: ';
|
|
1443
|
+
process.stdout.write(prompt);
|
|
1444
|
+
for await (const line of console) {
|
|
1445
|
+
code = line.trim();
|
|
1446
|
+
break;
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
if (!code) {
|
|
1451
|
+
console.error('✗ No authorization code provided.');
|
|
1452
|
+
process.exit(1);
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
const data = await AuthManager.exchangeAnthropicClaudeCode(code, verifier);
|
|
1456
|
+
AuthManager.save({
|
|
1457
|
+
anthropic_claude: {
|
|
1458
|
+
access_token: data.access_token,
|
|
1459
|
+
refresh_token: data.refresh_token,
|
|
1460
|
+
expires_at: Math.floor(Date.now() / 1000) + data.expires_in,
|
|
1461
|
+
},
|
|
1462
|
+
});
|
|
1463
|
+
console.log('\n✓ Successfully logged in to Anthropic Claude.');
|
|
1464
|
+
return;
|
|
1465
|
+
} catch (error) {
|
|
1466
|
+
console.error(
|
|
1467
|
+
'\n✗ Failed to login with Anthropic Claude:',
|
|
1468
|
+
error instanceof Error ? error.message : error
|
|
1469
|
+
);
|
|
1470
|
+
process.exit(1);
|
|
1471
|
+
}
|
|
1472
|
+
} else if (providerName === 'gemini' || providerName === 'google-gemini') {
|
|
1473
|
+
try {
|
|
1474
|
+
await AuthManager.loginGoogleGemini(options.project);
|
|
1475
|
+
console.log('\n✓ Successfully logged in to Google Gemini.');
|
|
1476
|
+
return;
|
|
1477
|
+
} catch (error) {
|
|
1478
|
+
console.error(
|
|
1479
|
+
'\n✗ Failed to login with Google Gemini:',
|
|
1480
|
+
error instanceof Error ? error.message : error
|
|
1481
|
+
);
|
|
1482
|
+
process.exit(1);
|
|
1483
|
+
}
|
|
1484
|
+
} else if (providerName === 'openai' || providerName === 'anthropic') {
|
|
826
1485
|
let key = options.token; // Use --token if provided as the API key
|
|
827
1486
|
|
|
828
1487
|
if (!key) {
|
|
829
|
-
console.log(`\n🔑 Login to ${
|
|
830
|
-
console.log(` Please provide your ${
|
|
1488
|
+
console.log(`\n🔑 Login to ${providerName.toUpperCase()}`);
|
|
1489
|
+
console.log(` Please provide your ${providerName.toUpperCase()} API key.\n`);
|
|
831
1490
|
const prompt = 'API Key: ';
|
|
832
1491
|
process.stdout.write(prompt);
|
|
833
1492
|
for await (const line of console) {
|
|
@@ -837,18 +1496,18 @@ auth
|
|
|
837
1496
|
}
|
|
838
1497
|
|
|
839
1498
|
if (key) {
|
|
840
|
-
if (
|
|
1499
|
+
if (providerName === 'openai') {
|
|
841
1500
|
AuthManager.save({ openai_api_key: key });
|
|
842
1501
|
} else {
|
|
843
1502
|
AuthManager.save({ anthropic_api_key: key });
|
|
844
1503
|
}
|
|
845
|
-
console.log(`\n✓ Successfully saved ${
|
|
1504
|
+
console.log(`\n✓ Successfully saved ${providerName.toUpperCase()} API key.`);
|
|
846
1505
|
} else {
|
|
847
1506
|
console.error('✗ No API key provided.');
|
|
848
1507
|
process.exit(1);
|
|
849
1508
|
}
|
|
850
1509
|
} else {
|
|
851
|
-
console.error(`✗ Unsupported provider: ${
|
|
1510
|
+
console.error(`✗ Unsupported provider: ${providerName}`);
|
|
852
1511
|
process.exit(1);
|
|
853
1512
|
}
|
|
854
1513
|
});
|
|
@@ -857,49 +1516,91 @@ auth
|
|
|
857
1516
|
.command('status')
|
|
858
1517
|
.description('Show authentication status')
|
|
859
1518
|
.argument('[provider]', 'Authentication provider')
|
|
860
|
-
.
|
|
861
|
-
.action(async (providerArg, options) => {
|
|
1519
|
+
.action(async (provider) => {
|
|
862
1520
|
const { AuthManager } = await import('./utils/auth-manager.ts');
|
|
863
1521
|
const auth = AuthManager.load();
|
|
864
|
-
const
|
|
1522
|
+
const providerName = provider?.toLowerCase();
|
|
865
1523
|
|
|
866
1524
|
console.log('\n🏛️ Authentication Status:');
|
|
867
1525
|
|
|
868
|
-
if (!
|
|
1526
|
+
if (!providerName || providerName === 'github' || providerName === 'copilot') {
|
|
869
1527
|
if (auth.github_token) {
|
|
870
1528
|
console.log(' ✓ Logged into GitHub');
|
|
871
1529
|
if (auth.copilot_expires_at) {
|
|
872
1530
|
const expires = new Date(auth.copilot_expires_at * 1000);
|
|
873
1531
|
console.log(` ✓ Copilot session expires: ${expires.toLocaleString()}`);
|
|
874
1532
|
}
|
|
875
|
-
} else if (
|
|
1533
|
+
} else if (providerName) {
|
|
876
1534
|
console.log(
|
|
877
1535
|
` ⊘ Not logged into GitHub. Run "keystone auth login github" to authenticate.`
|
|
878
1536
|
);
|
|
879
1537
|
}
|
|
880
1538
|
}
|
|
881
1539
|
|
|
882
|
-
if (!
|
|
1540
|
+
if (!providerName || providerName === 'openai' || providerName === 'openai-chatgpt') {
|
|
883
1541
|
if (auth.openai_api_key) {
|
|
884
1542
|
console.log(' ✓ OpenAI API key configured');
|
|
885
|
-
}
|
|
1543
|
+
}
|
|
1544
|
+
if (auth.openai_chatgpt) {
|
|
1545
|
+
console.log(' ✓ OpenAI ChatGPT subscription (OAuth) authenticated');
|
|
1546
|
+
if (auth.openai_chatgpt.expires_at) {
|
|
1547
|
+
const expires = new Date(auth.openai_chatgpt.expires_at * 1000);
|
|
1548
|
+
console.log(` Session expires: ${expires.toLocaleString()}`);
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
if (providerName && !auth.openai_api_key && !auth.openai_chatgpt) {
|
|
886
1553
|
console.log(
|
|
887
|
-
` ⊘ OpenAI
|
|
1554
|
+
` ⊘ OpenAI authentication not configured. Run "keystone auth login openai" or "keystone auth login openai-chatgpt" to authenticate.`
|
|
888
1555
|
);
|
|
889
1556
|
}
|
|
890
1557
|
}
|
|
891
1558
|
|
|
892
|
-
if (!
|
|
1559
|
+
if (!providerName || providerName === 'anthropic' || providerName === 'anthropic-claude') {
|
|
893
1560
|
if (auth.anthropic_api_key) {
|
|
894
1561
|
console.log(' ✓ Anthropic API key configured');
|
|
895
|
-
}
|
|
1562
|
+
}
|
|
1563
|
+
if (auth.anthropic_claude) {
|
|
1564
|
+
console.log(' ✓ Anthropic Claude subscription (OAuth) authenticated');
|
|
1565
|
+
if (auth.anthropic_claude.expires_at) {
|
|
1566
|
+
const expires = new Date(auth.anthropic_claude.expires_at * 1000);
|
|
1567
|
+
console.log(` Session expires: ${expires.toLocaleString()}`);
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
if (providerName && !auth.anthropic_api_key && !auth.anthropic_claude) {
|
|
896
1572
|
console.log(
|
|
897
|
-
` ⊘ Anthropic
|
|
1573
|
+
` ⊘ Anthropic authentication not configured. Run "keystone auth login anthropic" or "keystone auth login anthropic-claude" to authenticate.`
|
|
898
1574
|
);
|
|
899
1575
|
}
|
|
900
1576
|
}
|
|
901
1577
|
|
|
902
|
-
if (!
|
|
1578
|
+
if (!providerName || providerName === 'gemini' || providerName === 'google-gemini') {
|
|
1579
|
+
if (auth.google_gemini) {
|
|
1580
|
+
console.log(' ✓ Google Gemini subscription (OAuth) authenticated');
|
|
1581
|
+
if (auth.google_gemini.email) {
|
|
1582
|
+
console.log(` Account: ${auth.google_gemini.email}`);
|
|
1583
|
+
}
|
|
1584
|
+
if (auth.google_gemini.expires_at) {
|
|
1585
|
+
const expires = new Date(auth.google_gemini.expires_at * 1000);
|
|
1586
|
+
console.log(` Session expires: ${expires.toLocaleString()}`);
|
|
1587
|
+
}
|
|
1588
|
+
} else if (providerName) {
|
|
1589
|
+
console.log(
|
|
1590
|
+
` ⊘ Google Gemini authentication not configured. Run "keystone auth login gemini" to authenticate.`
|
|
1591
|
+
);
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
if (
|
|
1596
|
+
!auth.github_token &&
|
|
1597
|
+
!auth.openai_api_key &&
|
|
1598
|
+
!auth.openai_chatgpt &&
|
|
1599
|
+
!auth.anthropic_api_key &&
|
|
1600
|
+
!auth.anthropic_claude &&
|
|
1601
|
+
!auth.google_gemini &&
|
|
1602
|
+
!providerName
|
|
1603
|
+
) {
|
|
903
1604
|
console.log(' ⊘ No providers configured. Run "keystone auth login" to authenticate.');
|
|
904
1605
|
}
|
|
905
1606
|
});
|
|
@@ -908,29 +1609,42 @@ auth
|
|
|
908
1609
|
.command('logout')
|
|
909
1610
|
.description('Logout and clear authentication tokens')
|
|
910
1611
|
.argument('[provider]', 'Authentication provider')
|
|
911
|
-
.
|
|
912
|
-
'-p, --provider <provider>',
|
|
913
|
-
'Authentication provider (deprecated, use positional argument)'
|
|
914
|
-
)
|
|
915
|
-
.action(async (providerArg, options) => {
|
|
1612
|
+
.action(async (provider) => {
|
|
916
1613
|
const { AuthManager } = await import('./utils/auth-manager.ts');
|
|
917
|
-
const
|
|
1614
|
+
const providerName = provider?.toLowerCase();
|
|
918
1615
|
|
|
919
|
-
|
|
1616
|
+
const auth = AuthManager.load();
|
|
1617
|
+
|
|
1618
|
+
if (!providerName || providerName === 'github' || providerName === 'copilot') {
|
|
920
1619
|
AuthManager.save({
|
|
921
1620
|
github_token: undefined,
|
|
922
1621
|
copilot_token: undefined,
|
|
923
1622
|
copilot_expires_at: undefined,
|
|
924
1623
|
});
|
|
925
1624
|
console.log('✓ Successfully logged out of GitHub.');
|
|
926
|
-
} else if (
|
|
927
|
-
AuthManager.save({
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
console.log(
|
|
1625
|
+
} else if (providerName === 'openai' || providerName === 'openai-chatgpt') {
|
|
1626
|
+
AuthManager.save({
|
|
1627
|
+
openai_api_key: providerName === 'openai' ? undefined : auth.openai_api_key,
|
|
1628
|
+
openai_chatgpt: undefined,
|
|
1629
|
+
});
|
|
1630
|
+
console.log(
|
|
1631
|
+
`✓ Successfully cleared ${providerName === 'openai' ? 'OpenAI API key and ' : ''}ChatGPT session.`
|
|
1632
|
+
);
|
|
1633
|
+
} else if (providerName === 'anthropic' || providerName === 'anthropic-claude') {
|
|
1634
|
+
AuthManager.save({
|
|
1635
|
+
anthropic_api_key: providerName === 'anthropic' ? undefined : auth.anthropic_api_key,
|
|
1636
|
+
anthropic_claude: undefined,
|
|
1637
|
+
});
|
|
1638
|
+
console.log(
|
|
1639
|
+
`✓ Successfully cleared ${providerName === 'anthropic' ? 'Anthropic API key and ' : ''}Claude session.`
|
|
1640
|
+
);
|
|
1641
|
+
} else if (providerName === 'gemini' || providerName === 'google-gemini') {
|
|
1642
|
+
AuthManager.save({
|
|
1643
|
+
google_gemini: undefined,
|
|
1644
|
+
});
|
|
1645
|
+
console.log('✓ Successfully cleared Google Gemini session.');
|
|
932
1646
|
} else {
|
|
933
|
-
console.error(`✗ Unknown provider: ${
|
|
1647
|
+
console.error(`✗ Unknown provider: ${providerName}`);
|
|
934
1648
|
process.exit(1);
|
|
935
1649
|
}
|
|
936
1650
|
});
|
|
@@ -1010,7 +1724,12 @@ _keystone() {
|
|
|
1010
1724
|
validate)
|
|
1011
1725
|
_arguments ':path:_files'
|
|
1012
1726
|
;;
|
|
1013
|
-
resume
|
|
1727
|
+
resume)
|
|
1728
|
+
_arguments \\
|
|
1729
|
+
'(-i --input)'{-i,--input}'[Input values]:key=value' \\
|
|
1730
|
+
':run_id:__keystone_runs'
|
|
1731
|
+
;;
|
|
1732
|
+
logs)
|
|
1014
1733
|
_arguments ':run_id:__keystone_runs'
|
|
1015
1734
|
;;
|
|
1016
1735
|
auth)
|