@netnodeag/kraftwerk 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +339 -0
- package/bin/kraftwerk.js +24 -0
- package/dist/agent.d.ts +49 -0
- package/dist/agent.js +3 -0
- package/dist/cli/create-brief.d.ts +4 -0
- package/dist/cli/create-brief.js +146 -0
- package/dist/cli/doctor.d.ts +1 -0
- package/dist/cli/doctor.js +87 -0
- package/dist/cli/init.d.ts +1 -0
- package/dist/cli/init.js +81 -0
- package/dist/cli/kraftwerk.d.ts +1 -0
- package/dist/cli/kraftwerk.js +342 -0
- package/dist/cli/runs.d.ts +6 -0
- package/dist/cli/runs.js +120 -0
- package/dist/cli.d.ts +13 -0
- package/dist/cli.js +47 -0
- package/dist/config.d.ts +41 -0
- package/dist/config.js +97 -0
- package/dist/discover.d.ts +21 -0
- package/dist/discover.js +38 -0
- package/dist/envelope.d.ts +21 -0
- package/dist/envelope.js +61 -0
- package/dist/gates.d.ts +18 -0
- package/dist/gates.js +34 -0
- package/dist/harness.d.ts +66 -0
- package/dist/harness.js +14 -0
- package/dist/harnesses/claude.d.ts +2 -0
- package/dist/harnesses/claude.js +117 -0
- package/dist/harnesses/codex.d.ts +2 -0
- package/dist/harnesses/codex.js +158 -0
- package/dist/harnesses/pi.d.ts +2 -0
- package/dist/harnesses/pi.js +151 -0
- package/dist/harnesses/registry.d.ts +2 -0
- package/dist/harnesses/registry.js +19 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +22 -0
- package/dist/remote.d.ts +23 -0
- package/dist/remote.js +57 -0
- package/dist/run.d.ts +66 -0
- package/dist/run.js +278 -0
- package/dist/runner/docker.d.ts +38 -0
- package/dist/runner/docker.js +166 -0
- package/dist/stats.d.ts +46 -0
- package/dist/stats.js +65 -0
- package/dist/validate.d.ts +8 -0
- package/dist/validate.js +33 -0
- package/dist/workflow.d.ts +24 -0
- package/dist/workflow.js +11 -0
- package/dist/yaml.d.ts +21 -0
- package/dist/yaml.js +324 -0
- package/package.json +52 -0
- package/runner/Dockerfile +43 -0
- package/schema/workflow.schema.json +244 -0
package/dist/cli/init.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { SCHEMA_URL } from "../config.js";
|
|
5
|
+
/**
|
|
6
|
+
* `kraftwerk init` — make any repository a kraftwerk consumer in one
|
|
7
|
+
* command: kraftwerk.yml (root marker + config), workflows/ with a runnable
|
|
8
|
+
* example, and an output/ ignore entry. Everything is idempotent: existing
|
|
9
|
+
* files are left untouched and reported.
|
|
10
|
+
*/
|
|
11
|
+
const CONFIG_TEMPLATE = `# kraftwerk project config — also marks the project root for the CLI.
|
|
12
|
+
# All fields optional. Docs: https://github.com/NETNODEAG/kraftwerk
|
|
13
|
+
workflows: workflows # where workflows live
|
|
14
|
+
output: output # where run artifacts land (git-ignored)
|
|
15
|
+
`;
|
|
16
|
+
const WORKFLOW_TEMPLATE = `# yaml-language-server: $schema=${SCHEMA_URL}
|
|
17
|
+
name: hello
|
|
18
|
+
description: "Example workflow: one agent answers the request in a file"
|
|
19
|
+
workspace: |
|
|
20
|
+
Files: answer.md (the result).
|
|
21
|
+
agents:
|
|
22
|
+
assistant:
|
|
23
|
+
model: haiku
|
|
24
|
+
tools: [Read, Write, Edit]
|
|
25
|
+
persona: prompts/assistant.md
|
|
26
|
+
steps:
|
|
27
|
+
- name: answer
|
|
28
|
+
agent: assistant
|
|
29
|
+
prompt: prompts/answer.md
|
|
30
|
+
gates:
|
|
31
|
+
- file_non_empty: answer.md
|
|
32
|
+
`;
|
|
33
|
+
const PERSONA_TEMPLATE = `You are a precise assistant. You answer requests concisely and
|
|
34
|
+
concretely, and store the result as a file.
|
|
35
|
+
`;
|
|
36
|
+
const PROMPT_TEMPLATE = `Request: \${{ request }}
|
|
37
|
+
|
|
38
|
+
Answer the request and write the answer to answer.md
|
|
39
|
+
(Markdown, with a short heading).
|
|
40
|
+
`;
|
|
41
|
+
export async function runInit(cwd) {
|
|
42
|
+
const exists = async (p) => !!(await stat(p).catch(() => null));
|
|
43
|
+
const created = [];
|
|
44
|
+
const skipped = [];
|
|
45
|
+
const put = async (rel, content) => {
|
|
46
|
+
const abs = path.join(cwd, rel);
|
|
47
|
+
if (await exists(abs)) {
|
|
48
|
+
skipped.push(rel);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
await mkdir(path.dirname(abs), { recursive: true });
|
|
52
|
+
await writeFile(abs, content);
|
|
53
|
+
created.push(rel);
|
|
54
|
+
};
|
|
55
|
+
await put("kraftwerk.yml", CONFIG_TEMPLATE);
|
|
56
|
+
await put("workflows/hello/workflow.yml", WORKFLOW_TEMPLATE);
|
|
57
|
+
await put("workflows/hello/prompts/assistant.md", PERSONA_TEMPLATE);
|
|
58
|
+
await put("workflows/hello/prompts/answer.md", PROMPT_TEMPLATE);
|
|
59
|
+
// .gitignore: append output/ if it's not covered yet.
|
|
60
|
+
const gitignorePath = path.join(cwd, ".gitignore");
|
|
61
|
+
const gitignore = (await readFile(gitignorePath, "utf8").catch(() => null)) ?? null;
|
|
62
|
+
if (gitignore === null) {
|
|
63
|
+
await writeFile(gitignorePath, "output/\n");
|
|
64
|
+
created.push(".gitignore");
|
|
65
|
+
}
|
|
66
|
+
else if (!gitignore.split("\n").some((l) => l.trim().replace(/\/$/, "") === "output")) {
|
|
67
|
+
await appendFile(gitignorePath, `${gitignore.endsWith("\n") ? "" : "\n"}output/\n`);
|
|
68
|
+
created.push(".gitignore (output/ added)");
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
skipped.push(".gitignore");
|
|
72
|
+
}
|
|
73
|
+
for (const f of created)
|
|
74
|
+
console.log(`${chalk.green("✔")} ${f}`);
|
|
75
|
+
for (const f of skipped)
|
|
76
|
+
console.log(`${chalk.dim("• skipped (exists):")} ${chalk.dim(f)}`);
|
|
77
|
+
console.log(`\nNext steps:\n` +
|
|
78
|
+
` kraftwerk list\n` +
|
|
79
|
+
` kraftwerk run hello "What is kraftwerk?"\n` +
|
|
80
|
+
chalk.dim(` (editor validation comes from the $schema line in workflow.yml)`));
|
|
81
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { input, select } from "@inquirer/prompts";
|
|
4
|
+
import chalk from "chalk";
|
|
5
|
+
import Table from "cli-table3";
|
|
6
|
+
import { Command } from "commander";
|
|
7
|
+
import ora from "ora";
|
|
8
|
+
import { discoverWorkflows, findWorkflowsRoot } from "../discover.js";
|
|
9
|
+
import { isRemoteSpec, resolveRemote } from "../remote.js";
|
|
10
|
+
import { loadWorkflow, missingEnv } from "../yaml.js";
|
|
11
|
+
import { renderCreateBrief } from "./create-brief.js";
|
|
12
|
+
import { runDoctor } from "./doctor.js";
|
|
13
|
+
import { runInit } from "./init.js";
|
|
14
|
+
import { listRuns, showRun } from "./runs.js";
|
|
15
|
+
/**
|
|
16
|
+
* kraftwerk — the kraftwerk CLI.
|
|
17
|
+
*
|
|
18
|
+
* kraftwerk init scaffold kraftwerk.yml + workflows/ + example
|
|
19
|
+
* kraftwerk list discover + list workflows (--json, --from)
|
|
20
|
+
* kraftwerk run [workflow] [text] run one (prompts interactively if omitted)
|
|
21
|
+
* kraftwerk runs [show <id>] inspect past runs from their traces
|
|
22
|
+
* kraftwerk doctor preflight: harness CLIs, docker, workflows, env
|
|
23
|
+
* kraftwerk validate [paths...] validate without executing
|
|
24
|
+
*
|
|
25
|
+
* Workflows are auto-discovered under src/workflows/ (or workflows/), from
|
|
26
|
+
* any subdirectory (walk-up to kraftwerk.yml / workflows root / .git). No
|
|
27
|
+
* entry file, no registration, no local install needed — a repo with
|
|
28
|
+
* workflow folders plus `npx kraftwerk` is a complete consumer.
|
|
29
|
+
*
|
|
30
|
+
* Machine use (CI, cron, webhooks): `run --json` prints one JSON result on
|
|
31
|
+
* stdout and moves all narration to stderr; KRAFTWERK_YES=1 = --yes.
|
|
32
|
+
* Exit codes: 0 ok, 2 usage/config error, 3 run failed, 1 unexpected.
|
|
33
|
+
*
|
|
34
|
+
* `--from github:org/repo[@ref]` on list/run executes workflows straight
|
|
35
|
+
* from a git remote (shallow clone cache in ~/.cache/kraftwerk).
|
|
36
|
+
*/
|
|
37
|
+
/** Working directory for list/run: local cwd or the --from remote clone. */
|
|
38
|
+
async function resolveBaseDir(from) {
|
|
39
|
+
if (!from)
|
|
40
|
+
return process.cwd();
|
|
41
|
+
if (!isRemoteSpec(from)) {
|
|
42
|
+
console.error(chalk.red(`--from "${from}" not recognized — expected github:org/repo[@ref] or a git URL.`));
|
|
43
|
+
process.exit(2);
|
|
44
|
+
}
|
|
45
|
+
return (await resolveRemote(from)).dir;
|
|
46
|
+
}
|
|
47
|
+
const pkg = JSON.parse(await readFile(new URL("../../package.json", import.meta.url), "utf8"));
|
|
48
|
+
const program = new Command()
|
|
49
|
+
.name("kraftwerk")
|
|
50
|
+
.description("kraftwerk CLI: discover, validate, and run YAML workflows")
|
|
51
|
+
.version(pkg.version);
|
|
52
|
+
const agentLabel = (workflow) => workflow.meta.agents
|
|
53
|
+
.map((a) => {
|
|
54
|
+
const harness = a.harness && a.harness !== "claude" ? `${a.harness}:` : "";
|
|
55
|
+
const effort = a.effort ? `, ${a.effort}` : "";
|
|
56
|
+
return `${a.id} ${chalk.dim(`(${harness}${a.model}${effort})`)}`;
|
|
57
|
+
})
|
|
58
|
+
.join("\n");
|
|
59
|
+
program
|
|
60
|
+
.command("list")
|
|
61
|
+
.description("Discover and list the workflows in the current project")
|
|
62
|
+
.option("--json", "Machine-readable output (JSON on stdout)")
|
|
63
|
+
.option("--from <source>", "Workflows from a git remote: github:org/repo[@ref] or git URL")
|
|
64
|
+
.action(async (opts) => {
|
|
65
|
+
const baseDir = await resolveBaseDir(opts.from);
|
|
66
|
+
const spinner = opts.json ? undefined : ora("Discovering workflows ...").start();
|
|
67
|
+
const found = await discoverWorkflows(baseDir);
|
|
68
|
+
spinner?.stop();
|
|
69
|
+
if (opts.json) {
|
|
70
|
+
console.log(JSON.stringify(found.map((e) => ({
|
|
71
|
+
path: e.path,
|
|
72
|
+
name: e.workflow?.name,
|
|
73
|
+
description: e.workflow?.description,
|
|
74
|
+
steps: e.workflow?.meta.steps,
|
|
75
|
+
requires: e.workflow?.meta.requires,
|
|
76
|
+
agents: e.workflow?.meta.agents.map((a) => ({
|
|
77
|
+
id: a.id,
|
|
78
|
+
model: a.model,
|
|
79
|
+
harness: a.harness ?? "claude",
|
|
80
|
+
})),
|
|
81
|
+
error: e.error,
|
|
82
|
+
})), null, 2));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (found.length === 0) {
|
|
86
|
+
console.log(chalk.yellow("No workflows found (expected: src/workflows/ or workflows/ — `kraftwerk init` scaffolds a project)."));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const table = new Table({
|
|
90
|
+
head: ["workflow", "description", "steps", "agents"].map((h) => chalk.bold(h)),
|
|
91
|
+
wordWrap: true,
|
|
92
|
+
colWidths: [16, 40, 7, 36],
|
|
93
|
+
});
|
|
94
|
+
for (const entry of found) {
|
|
95
|
+
if (entry.workflow) {
|
|
96
|
+
const requires = entry.workflow.meta.requires;
|
|
97
|
+
table.push([
|
|
98
|
+
chalk.cyan(entry.workflow.name),
|
|
99
|
+
entry.workflow.description +
|
|
100
|
+
(requires.length ? chalk.dim(`\nrequires: ${requires.join(", ")}`) : ""),
|
|
101
|
+
String(entry.workflow.meta.steps.length),
|
|
102
|
+
agentLabel(entry.workflow),
|
|
103
|
+
]);
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
table.push([
|
|
107
|
+
chalk.red(path.basename(entry.path)),
|
|
108
|
+
chalk.red(entry.error ?? "unknown error"),
|
|
109
|
+
"—",
|
|
110
|
+
"—",
|
|
111
|
+
]);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
console.log(table.toString());
|
|
115
|
+
});
|
|
116
|
+
program
|
|
117
|
+
.command("run")
|
|
118
|
+
.description("Run a workflow (prompts interactively for anything missing)")
|
|
119
|
+
.argument("[workflow]", "Workflow name from `kraftwerk list`")
|
|
120
|
+
.argument("[request...]", "The request: topic, URL, project idea, ...")
|
|
121
|
+
.option("--yes", "Auto-confirm approval gates (also: KRAFTWERK_YES=1)")
|
|
122
|
+
.option("--verbose", "Stream the agent narration")
|
|
123
|
+
.option("--json", "Non-interactive: JSON result on stdout, narration on stderr")
|
|
124
|
+
.option("--quiet", "Suppress narration (result/errors only)")
|
|
125
|
+
.option("--from <source>", "Workflows from a git remote: github:org/repo[@ref] or git URL")
|
|
126
|
+
.option("--sandbox", "Run inside the Docker sandbox container (image: kraftwerk-runner)")
|
|
127
|
+
.option("--ssh", "Forward the SSH agent + known_hosts into the sandbox (only with --sandbox)")
|
|
128
|
+
.option("--run-id <id>", "Pin the run folder name (output/<id>), e.g. for external triggers")
|
|
129
|
+
.action(async (name, requestParts, opts) => {
|
|
130
|
+
const machine = !!opts.json;
|
|
131
|
+
const fail = (code, message) => {
|
|
132
|
+
if (machine)
|
|
133
|
+
console.log(JSON.stringify({ ok: false, error: message }));
|
|
134
|
+
console.error(chalk.red(message));
|
|
135
|
+
process.exit(code);
|
|
136
|
+
};
|
|
137
|
+
const baseDir = await resolveBaseDir(opts.from);
|
|
138
|
+
const spinner = machine ? undefined : ora("Loading workflows ...").start();
|
|
139
|
+
const found = await discoverWorkflows(baseDir);
|
|
140
|
+
spinner?.stop();
|
|
141
|
+
const valid = found.filter((e) => e.workflow);
|
|
142
|
+
if (valid.length === 0)
|
|
143
|
+
fail(2, "No valid workflows found.");
|
|
144
|
+
let entry = name ? valid.find((e) => e.workflow.name === name) : undefined;
|
|
145
|
+
if (name && !entry) {
|
|
146
|
+
fail(2, `Workflow "${name}" not found. Available: ${valid.map((e) => e.workflow.name).join(", ")}`);
|
|
147
|
+
}
|
|
148
|
+
if (!entry && machine)
|
|
149
|
+
fail(2, "No workflow given (--json is non-interactive).");
|
|
150
|
+
entry ??= await select({
|
|
151
|
+
message: "Which workflow?",
|
|
152
|
+
choices: valid.map((e) => ({
|
|
153
|
+
name: `${e.workflow.name} — ${e.workflow.description}`,
|
|
154
|
+
value: e,
|
|
155
|
+
})),
|
|
156
|
+
});
|
|
157
|
+
const workflow = entry.workflow;
|
|
158
|
+
let request = (requestParts ?? []).join(" ").trim();
|
|
159
|
+
if (!request && machine)
|
|
160
|
+
fail(2, "No request given (--json is non-interactive).");
|
|
161
|
+
if (!request)
|
|
162
|
+
request = (await input({ message: "Request (topic, URL, ...):" })).trim();
|
|
163
|
+
if (!request)
|
|
164
|
+
fail(2, "No request given.");
|
|
165
|
+
const missing = missingEnv(workflow.meta.requires);
|
|
166
|
+
if (missing.length > 0) {
|
|
167
|
+
fail(2, `Workflow "${workflow.name}" needs environment variables that are missing: ${missing.join(", ")}`);
|
|
168
|
+
}
|
|
169
|
+
if (opts.sandbox) {
|
|
170
|
+
const { runSandboxed } = await import("../runner/docker.js");
|
|
171
|
+
const handle = await runSandboxed({
|
|
172
|
+
projectRoot: baseDir,
|
|
173
|
+
workflowPath: entry.path,
|
|
174
|
+
workflowName: workflow.name,
|
|
175
|
+
request,
|
|
176
|
+
runId: opts.runId,
|
|
177
|
+
ssh: !!opts.ssh,
|
|
178
|
+
mode: "attach",
|
|
179
|
+
});
|
|
180
|
+
console.log(chalk.dim(`sandbox ${handle.containerName} → ${handle.runDir}`));
|
|
181
|
+
process.exit(await handle.finished);
|
|
182
|
+
}
|
|
183
|
+
if (opts.runId) {
|
|
184
|
+
process.env.KRAFTWERK_RUN_DIR = path.resolve("output", opts.runId);
|
|
185
|
+
}
|
|
186
|
+
else if (!process.env.KRAFTWERK_RUN_DIR) {
|
|
187
|
+
// Root the run dir explicitly: at the project's output dir (honors
|
|
188
|
+
// kraftwerk.yml `output:` and works from subdirs); for remote runs at
|
|
189
|
+
// the caller's cwd — never inside the clone cache.
|
|
190
|
+
const { runStamp } = await import("../workflow.js");
|
|
191
|
+
const { resolveProject } = await import("../config.js");
|
|
192
|
+
const outBase = opts.from ? path.resolve("output") : (await resolveProject(baseDir)).outputDir;
|
|
193
|
+
process.env.KRAFTWERK_RUN_DIR = path.join(outBase, `run-${runStamp()}`);
|
|
194
|
+
}
|
|
195
|
+
// Machine/quiet mode: workflow narration must not pollute stdout — the
|
|
196
|
+
// engine logs via console.log, so reroute it for the duration of the run.
|
|
197
|
+
const realLog = console.log;
|
|
198
|
+
if (machine)
|
|
199
|
+
console.log = (...args) => console.error(...args);
|
|
200
|
+
else if (opts.quiet)
|
|
201
|
+
console.log = () => { };
|
|
202
|
+
const autoApprove = !!opts.yes || process.env.KRAFTWERK_YES === "1";
|
|
203
|
+
try {
|
|
204
|
+
const result = await workflow.run({ request, autoApprove, verbose: !!opts.verbose });
|
|
205
|
+
console.log = realLog;
|
|
206
|
+
if (machine) {
|
|
207
|
+
console.log(JSON.stringify({ ok: true, workflow: workflow.name, request, ...result }, null, 2));
|
|
208
|
+
}
|
|
209
|
+
else if (opts.quiet && result) {
|
|
210
|
+
console.log(`ok ${workflow.name} → ${result.runDir}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
console.log = realLog;
|
|
215
|
+
const message = err.message;
|
|
216
|
+
if (machine)
|
|
217
|
+
console.log(JSON.stringify({ ok: false, workflow: workflow.name, request, error: message }));
|
|
218
|
+
console.error(chalk.red(message));
|
|
219
|
+
process.exit(3);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
program
|
|
223
|
+
.command("init")
|
|
224
|
+
.description("Scaffold the project: kraftwerk.yml, workflows/ with an example, .gitignore")
|
|
225
|
+
.action(async () => {
|
|
226
|
+
await runInit(process.cwd());
|
|
227
|
+
});
|
|
228
|
+
program
|
|
229
|
+
.command("doctor")
|
|
230
|
+
.description("Preflight: harness CLIs, docker, workflows, declared environment variables")
|
|
231
|
+
.action(async () => {
|
|
232
|
+
await runDoctor(process.cwd());
|
|
233
|
+
});
|
|
234
|
+
const runs = program
|
|
235
|
+
.command("runs")
|
|
236
|
+
.description("Inspect past runs (from output/*/trace.jsonl)");
|
|
237
|
+
runs
|
|
238
|
+
.command("list", { isDefault: true })
|
|
239
|
+
.description("List runs (newest first)")
|
|
240
|
+
.option("--json", "Machine-readable output")
|
|
241
|
+
.action(async (opts) => {
|
|
242
|
+
await listRuns(process.cwd(), opts);
|
|
243
|
+
});
|
|
244
|
+
runs
|
|
245
|
+
.command("show")
|
|
246
|
+
.description("Show one run in detail: phases, gates, cost")
|
|
247
|
+
.argument("<runId>", "Folder name under output/, see `kraftwerk runs`")
|
|
248
|
+
.option("--json", "Machine-readable output (all trace events)")
|
|
249
|
+
.action(async (runId, opts) => {
|
|
250
|
+
await showRun(process.cwd(), runId, opts);
|
|
251
|
+
});
|
|
252
|
+
const runner = program
|
|
253
|
+
.command("runner")
|
|
254
|
+
.description("Manage the Docker sandbox runner (build the image, see/stop running runs)");
|
|
255
|
+
runner
|
|
256
|
+
.command("build")
|
|
257
|
+
.description("Build/update the kraftwerk-runner image")
|
|
258
|
+
.action(async () => {
|
|
259
|
+
const { buildImage, dockerAvailable } = await import("../runner/docker.js");
|
|
260
|
+
if (!dockerAvailable()) {
|
|
261
|
+
console.error(chalk.red("Docker daemon not reachable — is Docker running?"));
|
|
262
|
+
process.exit(1);
|
|
263
|
+
}
|
|
264
|
+
await buildImage();
|
|
265
|
+
console.log(chalk.green("✔ Image kraftwerk-runner built"));
|
|
266
|
+
});
|
|
267
|
+
runner
|
|
268
|
+
.command("ps")
|
|
269
|
+
.description("List running sandbox runs")
|
|
270
|
+
.action(async () => {
|
|
271
|
+
const { listSandboxes } = await import("../runner/docker.js");
|
|
272
|
+
const rows = listSandboxes();
|
|
273
|
+
if (rows.length === 0) {
|
|
274
|
+
console.log(chalk.dim("No running sandbox runs."));
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
for (const r of rows) {
|
|
278
|
+
console.log(`${chalk.cyan(r.runId)} ${r.workflow} ${chalk.dim(r.status)}`);
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
runner
|
|
282
|
+
.command("stop")
|
|
283
|
+
.description("Stop a running sandbox run")
|
|
284
|
+
.argument("<runId>", "Run-Id (run-...)")
|
|
285
|
+
.action(async (runId) => {
|
|
286
|
+
const { stopSandbox } = await import("../runner/docker.js");
|
|
287
|
+
if (stopSandbox(runId.replace(/^kw-/, ""))) {
|
|
288
|
+
console.log(chalk.green(`✔ ${runId} stopped`));
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
console.error(chalk.red(`No running container for ${runId}.`));
|
|
292
|
+
process.exit(1);
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
// LLM-facing, veloop-style: prints a self-contained brief for the agent
|
|
296
|
+
// that then authors the workflow folder with this CLI.
|
|
297
|
+
program
|
|
298
|
+
.command("create")
|
|
299
|
+
.description("Print a brief for an LLM agent that builds a workflow from the description")
|
|
300
|
+
.argument("<spec...>", "What the workflow should do (free text)")
|
|
301
|
+
.action(async (specParts) => {
|
|
302
|
+
const spec = specParts.join(" ").trim();
|
|
303
|
+
if (!spec) {
|
|
304
|
+
console.error(chalk.red('Description missing. Example: kraftwerk create "A workflow that writes and reviews release notes"'));
|
|
305
|
+
process.exit(1);
|
|
306
|
+
}
|
|
307
|
+
const root = await findWorkflowsRoot(process.cwd());
|
|
308
|
+
console.log(renderCreateBrief({
|
|
309
|
+
spec,
|
|
310
|
+
workflowsRoot: root ? path.relative(process.cwd(), root) : undefined,
|
|
311
|
+
}));
|
|
312
|
+
});
|
|
313
|
+
program
|
|
314
|
+
.command("validate")
|
|
315
|
+
.description("Validate workflows without executing them (schema + semantics + files)")
|
|
316
|
+
.argument("[paths...]", "workflow.yml files or workflow folders; without paths: all discovered")
|
|
317
|
+
.action(async (paths) => {
|
|
318
|
+
let targets = paths;
|
|
319
|
+
if (targets.length === 0) {
|
|
320
|
+
const spinner = ora("Discovering workflows ...").start();
|
|
321
|
+
targets = (await discoverWorkflows(process.cwd())).map((e) => e.path);
|
|
322
|
+
spinner.stop();
|
|
323
|
+
if (targets.length === 0) {
|
|
324
|
+
console.log(chalk.yellow("No workflows found."));
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
let failures = 0;
|
|
329
|
+
for (const target of targets) {
|
|
330
|
+
try {
|
|
331
|
+
const workflow = await loadWorkflow(target);
|
|
332
|
+
console.log(`${chalk.green("✔")} ${target} — ${chalk.cyan(workflow.name)} ` +
|
|
333
|
+
chalk.dim(`(${workflow.meta.steps.length} steps, ${workflow.meta.agents.length} agents)`));
|
|
334
|
+
}
|
|
335
|
+
catch (err) {
|
|
336
|
+
failures += 1;
|
|
337
|
+
console.error(`${chalk.red("✖")} ${target}\n${chalk.red(err.message)}`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
process.exit(failures > 0 ? 1 : 0);
|
|
341
|
+
});
|
|
342
|
+
await program.parseAsync(process.argv);
|
package/dist/cli/runs.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import Table from "cli-table3";
|
|
5
|
+
import { resolveProject } from "../config.js";
|
|
6
|
+
import { fmtDuration, fmtTokens } from "../stats.js";
|
|
7
|
+
async function readRun(dir) {
|
|
8
|
+
const tracePath = path.join(dir, "trace.jsonl");
|
|
9
|
+
const raw = await readFile(tracePath, "utf8").catch(() => null);
|
|
10
|
+
if (raw === null)
|
|
11
|
+
return undefined;
|
|
12
|
+
const events = raw
|
|
13
|
+
.split("\n")
|
|
14
|
+
.filter((l) => l.trim())
|
|
15
|
+
.flatMap((l) => {
|
|
16
|
+
try {
|
|
17
|
+
return [JSON.parse(l)];
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
const start = events.find((e) => e.event === "run_start");
|
|
24
|
+
const summary = events.find((e) => e.event === "run_summary");
|
|
25
|
+
const failed = events.some((e) => e.event === "phase_end" && (e.status === "failed" || e.status === "blocked"));
|
|
26
|
+
return {
|
|
27
|
+
id: path.basename(dir),
|
|
28
|
+
dir,
|
|
29
|
+
workflow: start?.workflow,
|
|
30
|
+
request: start?.request,
|
|
31
|
+
startedAt: start?.ts,
|
|
32
|
+
status: summary ? "ok" : failed ? "failed" : "incomplete",
|
|
33
|
+
durationMs: summary?.total?.durationMs,
|
|
34
|
+
costUsd: summary?.total?.costUsd,
|
|
35
|
+
events,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
async function collectRuns(cwd) {
|
|
39
|
+
const { outputDir } = await resolveProject(cwd);
|
|
40
|
+
const entries = await readdir(outputDir, { withFileTypes: true }).catch(() => []);
|
|
41
|
+
const runs = [];
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
if (!entry.isDirectory())
|
|
44
|
+
continue;
|
|
45
|
+
const info = await readRun(path.join(outputDir, entry.name));
|
|
46
|
+
if (info)
|
|
47
|
+
runs.push(info);
|
|
48
|
+
}
|
|
49
|
+
runs.sort((a, b) => (b.startedAt ?? b.id).localeCompare(a.startedAt ?? a.id));
|
|
50
|
+
return { outputDir, runs };
|
|
51
|
+
}
|
|
52
|
+
const STATUS_LABEL = {
|
|
53
|
+
ok: chalk.green("ok"),
|
|
54
|
+
failed: chalk.red("failed"),
|
|
55
|
+
incomplete: chalk.yellow("incomplete"),
|
|
56
|
+
};
|
|
57
|
+
export async function listRuns(cwd, opts = {}) {
|
|
58
|
+
const { outputDir, runs } = await collectRuns(cwd);
|
|
59
|
+
if (opts.json) {
|
|
60
|
+
console.log(JSON.stringify(runs.map(({ events: _events, ...rest }) => rest), null, 2));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (runs.length === 0) {
|
|
64
|
+
console.log(chalk.dim(`No runs under ${outputDir}.`));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const table = new Table({
|
|
68
|
+
head: ["run", "workflow", "status", "duration", "cost", "request"].map((h) => chalk.bold(h)),
|
|
69
|
+
wordWrap: true,
|
|
70
|
+
colWidths: [26, 14, 12, 8, 9, 30],
|
|
71
|
+
});
|
|
72
|
+
for (const r of runs) {
|
|
73
|
+
table.push([
|
|
74
|
+
r.id,
|
|
75
|
+
r.workflow ?? "—",
|
|
76
|
+
STATUS_LABEL[r.status],
|
|
77
|
+
r.durationMs !== undefined ? fmtDuration(r.durationMs) : "—",
|
|
78
|
+
r.costUsd !== undefined ? `$${r.costUsd.toFixed(2)}` : "—",
|
|
79
|
+
r.request ?? "—",
|
|
80
|
+
]);
|
|
81
|
+
}
|
|
82
|
+
console.log(table.toString());
|
|
83
|
+
}
|
|
84
|
+
export async function showRun(cwd, id, opts = {}) {
|
|
85
|
+
const { outputDir } = await resolveProject(cwd);
|
|
86
|
+
const dir = path.join(outputDir, id);
|
|
87
|
+
if (!(await stat(dir).catch(() => null))) {
|
|
88
|
+
console.error(chalk.red(`Run "${id}" not found under ${outputDir}.`));
|
|
89
|
+
process.exit(2);
|
|
90
|
+
}
|
|
91
|
+
const info = await readRun(dir);
|
|
92
|
+
if (!info) {
|
|
93
|
+
console.error(chalk.red(`${id}: no trace.jsonl — not a kraftwerk run?`));
|
|
94
|
+
process.exit(2);
|
|
95
|
+
}
|
|
96
|
+
if (opts.json) {
|
|
97
|
+
console.log(JSON.stringify(info, null, 2));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
console.log(`${chalk.cyan(info.workflow ?? "?")} ${STATUS_LABEL[info.status]} ${chalk.dim(info.startedAt ?? "")}`);
|
|
101
|
+
if (info.request)
|
|
102
|
+
console.log(chalk.dim(`request: ${info.request}`));
|
|
103
|
+
console.log(chalk.dim(`dir: ${info.dir}\n`));
|
|
104
|
+
for (const e of info.events) {
|
|
105
|
+
if (e.event === "phase_end") {
|
|
106
|
+
const icon = e.status === "ok" ? chalk.green("✔") : chalk.red("✖");
|
|
107
|
+
const s = e.stats;
|
|
108
|
+
const detail = s
|
|
109
|
+
? ` ${chalk.dim(`${fmtDuration(s.durationMs)} | ${fmtTokens(s.inputTokens + s.cacheReadTokens + s.cacheCreationTokens)} in / ${fmtTokens(s.outputTokens)} out | $${s.costUsd.toFixed(4)} | ${s.attempts} attempt(s)`)}`
|
|
110
|
+
: "";
|
|
111
|
+
console.log(`${icon} ${e.phase}${detail}`);
|
|
112
|
+
}
|
|
113
|
+
if (e.event === "gate_result" && e.passed === false) {
|
|
114
|
+
console.log(` ${chalk.red("gate")} ${e.gate}: ${e.failure}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (info.durationMs !== undefined) {
|
|
118
|
+
console.log(chalk.dim(`\ntotal: ${fmtDuration(info.durationMs)} | $${(info.costUsd ?? 0).toFixed(4)}`));
|
|
119
|
+
}
|
|
120
|
+
}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { WorkflowDefinition } from "./workflow.js";
|
|
2
|
+
/**
|
|
3
|
+
* CLI registry dispatch: one entry point, one name per workflow. A consumer's
|
|
4
|
+
* whole entry file is one call:
|
|
5
|
+
*
|
|
6
|
+
* runCli({ [contentWorkflow.name]: contentWorkflow });
|
|
7
|
+
*
|
|
8
|
+
* npm start -- <workflow> "topic or source URL"
|
|
9
|
+
* npm start -- <workflow> --yes "..." # auto-approve
|
|
10
|
+
* npm start -- <workflow> --verbose "..." # + agent narration
|
|
11
|
+
* npm start -- validate <path> ... # validate YAML workflows
|
|
12
|
+
*/
|
|
13
|
+
export declare function runCli(workflows: Record<string, WorkflowDefinition>): void;
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { validateWorkflows } from "./validate.js";
|
|
2
|
+
/**
|
|
3
|
+
* CLI registry dispatch: one entry point, one name per workflow. A consumer's
|
|
4
|
+
* whole entry file is one call:
|
|
5
|
+
*
|
|
6
|
+
* runCli({ [contentWorkflow.name]: contentWorkflow });
|
|
7
|
+
*
|
|
8
|
+
* npm start -- <workflow> "topic or source URL"
|
|
9
|
+
* npm start -- <workflow> --yes "..." # auto-approve
|
|
10
|
+
* npm start -- <workflow> --verbose "..." # + agent narration
|
|
11
|
+
* npm start -- validate <path> ... # validate YAML workflows
|
|
12
|
+
*/
|
|
13
|
+
export function runCli(workflows) {
|
|
14
|
+
const usage = () => {
|
|
15
|
+
console.error('Usage: npm start -- <workflow> [--yes] [--verbose] "<topic or source URL>"');
|
|
16
|
+
console.error(' npm start -- validate <workflow.yml | workflow-folder> ...');
|
|
17
|
+
console.error("\nAvailable workflows:");
|
|
18
|
+
for (const wf of Object.values(workflows)) {
|
|
19
|
+
console.error(` ${wf.name.padEnd(12)} ${wf.description}`);
|
|
20
|
+
}
|
|
21
|
+
process.exit(1);
|
|
22
|
+
};
|
|
23
|
+
const main = async () => {
|
|
24
|
+
const args = process.argv.slice(2);
|
|
25
|
+
const autoApprove = args.includes("--yes");
|
|
26
|
+
const verbose = args.includes("--verbose") || args.includes("-v");
|
|
27
|
+
const positional = args.filter((a) => !["--yes", "--verbose", "-v"].includes(a));
|
|
28
|
+
const [name, ...rest] = positional;
|
|
29
|
+
// Reserved subcommand: validate YAML workflows without executing them.
|
|
30
|
+
if (name === "validate") {
|
|
31
|
+
if (rest.length === 0)
|
|
32
|
+
usage();
|
|
33
|
+
process.exit((await validateWorkflows(rest)) > 0 ? 1 : 0);
|
|
34
|
+
}
|
|
35
|
+
const workflow = name ? workflows[name] : undefined;
|
|
36
|
+
if (!workflow)
|
|
37
|
+
usage();
|
|
38
|
+
const request = rest.join(" ").trim();
|
|
39
|
+
if (!request)
|
|
40
|
+
usage();
|
|
41
|
+
await workflow.run({ request, autoApprove, verbose });
|
|
42
|
+
};
|
|
43
|
+
main().catch((err) => {
|
|
44
|
+
console.error(err);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
});
|
|
47
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project resolution + optional project config.
|
|
3
|
+
*
|
|
4
|
+
* `kraftwerk.yml` at the project root is both the config file and the
|
|
5
|
+
* root marker, so every CLI command works from any subdirectory. Without
|
|
6
|
+
* it, the walk-up falls back to the first ancestor containing a workflows
|
|
7
|
+
* root (src/workflows/ or workflows/), then to the first .git directory,
|
|
8
|
+
* then to the starting cwd.
|
|
9
|
+
*
|
|
10
|
+
* All fields are optional — a valid kraftwerk.yml may be empty:
|
|
11
|
+
*
|
|
12
|
+
* workflows: src/workflows # workflows root, relative to the file
|
|
13
|
+
* output: output # run-artifact directory, relative to the file
|
|
14
|
+
*/
|
|
15
|
+
/** Stable, versionless URL of the workflow JSON schema (editor validation). */
|
|
16
|
+
export declare const SCHEMA_URL = "https://raw.githubusercontent.com/NETNODEAG/kraftwerk/main/kraftwerk/schema/workflow.schema.json";
|
|
17
|
+
export declare const CONFIG_FILENAMES: string[];
|
|
18
|
+
export interface ProjectConfig {
|
|
19
|
+
/** Workflows root relative to the project root. */
|
|
20
|
+
workflows?: string;
|
|
21
|
+
/** Run-artifact directory relative to the project root. Default: output */
|
|
22
|
+
output?: string;
|
|
23
|
+
}
|
|
24
|
+
export interface Project {
|
|
25
|
+
/** Absolute project root the CLI operates on. */
|
|
26
|
+
root: string;
|
|
27
|
+
/** Parsed kraftwerk.yml, {} if none exists. */
|
|
28
|
+
config: ProjectConfig;
|
|
29
|
+
/** Absolute path of the config file, if one exists. */
|
|
30
|
+
configPath?: string;
|
|
31
|
+
/** Absolute workflows root, if one exists. */
|
|
32
|
+
workflowsRoot?: string;
|
|
33
|
+
/** Absolute run-artifact directory (may not exist yet). */
|
|
34
|
+
outputDir: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the project for a cwd: walk up until a kraftwerk.yml or a
|
|
38
|
+
* workflows root appears; a .git directory is the fallback root, the cwd
|
|
39
|
+
* itself the last resort.
|
|
40
|
+
*/
|
|
41
|
+
export declare function resolveProject(cwd: string): Promise<Project>;
|