@flue/cli 0.0.6 → 0.0.8
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/dist/flue.js +174 -4
- package/package.json +2 -2
package/dist/flue.js
CHANGED
|
@@ -6,8 +6,9 @@ import { pathToFileURL } from "node:url";
|
|
|
6
6
|
//#region bin/flue.js
|
|
7
7
|
const OPENCODE_URL = "http://localhost:48765";
|
|
8
8
|
let openCodeProcess = null;
|
|
9
|
+
let eventStreamAbort = null;
|
|
9
10
|
function printUsage() {
|
|
10
|
-
console.error("Usage: flue run <workflowPath> [--args <json>] [--branch <name>]");
|
|
11
|
+
console.error("Usage: flue run <workflowPath> [--args <json>] [--branch <name>] [--model <provider/model>]");
|
|
11
12
|
}
|
|
12
13
|
function parseArgs(argv) {
|
|
13
14
|
const [command, workflowPath, ...rest] = argv;
|
|
@@ -17,6 +18,7 @@ function parseArgs(argv) {
|
|
|
17
18
|
}
|
|
18
19
|
let argsJson;
|
|
19
20
|
let branch;
|
|
21
|
+
let model;
|
|
20
22
|
for (let i = 0; i < rest.length; i += 1) {
|
|
21
23
|
const arg = rest[i];
|
|
22
24
|
if (arg === "--args") {
|
|
@@ -37,6 +39,15 @@ function parseArgs(argv) {
|
|
|
37
39
|
i += 1;
|
|
38
40
|
continue;
|
|
39
41
|
}
|
|
42
|
+
if (arg === "--model") {
|
|
43
|
+
model = rest[i + 1];
|
|
44
|
+
if (!model) {
|
|
45
|
+
console.error("Missing value for --model");
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
i += 1;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
40
51
|
console.error(`Unknown argument: ${arg}`);
|
|
41
52
|
printUsage();
|
|
42
53
|
process.exit(1);
|
|
@@ -44,11 +55,140 @@ function parseArgs(argv) {
|
|
|
44
55
|
return {
|
|
45
56
|
workflowPath,
|
|
46
57
|
argsJson,
|
|
47
|
-
branch
|
|
58
|
+
branch,
|
|
59
|
+
model
|
|
48
60
|
};
|
|
49
61
|
}
|
|
62
|
+
/** Parse "provider/model" string into { providerID, modelID }. */
|
|
63
|
+
function parseModel(modelStr) {
|
|
64
|
+
const slashIndex = modelStr.indexOf("/");
|
|
65
|
+
if (slashIndex === -1) {
|
|
66
|
+
console.error(`Invalid --model format: "${modelStr}". Expected "provider/model" (e.g. "anthropic/claude-sonnet-4-5").`);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
providerID: modelStr.slice(0, slashIndex),
|
|
71
|
+
modelID: modelStr.slice(slashIndex + 1)
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Connect to OpenCode's SSE /event endpoint and log events to stderr.
|
|
76
|
+
* Returns an object with an abort() method to close the connection.
|
|
77
|
+
*/
|
|
78
|
+
function startEventStream(workdir) {
|
|
79
|
+
const controller = new AbortController();
|
|
80
|
+
const sessionNames = /* @__PURE__ */ new Map();
|
|
81
|
+
const textBuffers = /* @__PURE__ */ new Map();
|
|
82
|
+
_consumeEventStream(controller.signal, workdir, sessionNames, textBuffers).catch((err) => {
|
|
83
|
+
if (err.name !== "AbortError") console.error(`[opencode] event stream error: ${err.message}`);
|
|
84
|
+
});
|
|
85
|
+
return { abort() {
|
|
86
|
+
for (const [sessionId, text] of textBuffers) if (text) {
|
|
87
|
+
const name = sessionNames.get(sessionId) ?? sessionId.slice(0, 12);
|
|
88
|
+
for (const line of text.split("\n")) if (line) console.error(`[opencode] (${name}) > ${line}`);
|
|
89
|
+
}
|
|
90
|
+
controller.abort();
|
|
91
|
+
} };
|
|
92
|
+
}
|
|
93
|
+
async function _consumeEventStream(signal, workdir, sessionNames, textBuffers) {
|
|
94
|
+
const { transformEvent } = await import("@flue/client");
|
|
95
|
+
const url = `${OPENCODE_URL}/event?directory=${encodeURIComponent(workdir)}`;
|
|
96
|
+
const res = await fetch(url, { signal });
|
|
97
|
+
if (!res.ok || !res.body) {
|
|
98
|
+
console.error(`[opencode] failed to connect to event stream (HTTP ${res.status})`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const decoder = new TextDecoder();
|
|
102
|
+
let buffer = "";
|
|
103
|
+
for await (const chunk of res.body) {
|
|
104
|
+
if (signal.aborted) break;
|
|
105
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
106
|
+
const parts = buffer.split("\n\n");
|
|
107
|
+
buffer = parts.pop() ?? "";
|
|
108
|
+
for (const part of parts) {
|
|
109
|
+
if (!part.trim()) continue;
|
|
110
|
+
const dataLines = [];
|
|
111
|
+
for (const line of part.split("\n")) if (line.startsWith("data: ")) dataLines.push(line.slice(6));
|
|
112
|
+
else if (line.startsWith("data:")) dataLines.push(line.slice(5));
|
|
113
|
+
if (dataLines.length === 0) continue;
|
|
114
|
+
let raw;
|
|
115
|
+
try {
|
|
116
|
+
raw = JSON.parse(dataLines.join("\n"));
|
|
117
|
+
} catch {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (raw.type === "session.created" || raw.type === "session.updated") {
|
|
121
|
+
const info = raw.properties?.info;
|
|
122
|
+
if (info?.id && info?.title) sessionNames.set(info.id, info.title);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const event = transformEvent(raw);
|
|
126
|
+
if (!event) continue;
|
|
127
|
+
logEvent(event, sessionNames.get(event.sessionId) ?? event.sessionId.slice(0, 12), textBuffers);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Format and log a single FlueEvent to stderr.
|
|
133
|
+
*/
|
|
134
|
+
function logEvent(event, sessionName, textBuffers) {
|
|
135
|
+
const prefix = `[opencode] (${sessionName})`;
|
|
136
|
+
switch (event.type) {
|
|
137
|
+
case "tool.pending":
|
|
138
|
+
console.error(`${prefix} tool:pending ${event.tool} — ${event.input}`);
|
|
139
|
+
break;
|
|
140
|
+
case "tool.running":
|
|
141
|
+
flushTextBuffer(textBuffers, event.sessionId, sessionName);
|
|
142
|
+
console.error(`${prefix} tool:running ${event.tool} — ${event.input}`);
|
|
143
|
+
break;
|
|
144
|
+
case "tool.complete": {
|
|
145
|
+
const dur = event.duration ? ` (${(event.duration / 1e3).toFixed(1)}s)` : "";
|
|
146
|
+
console.error(`${prefix} tool:complete ${event.tool}${dur} — ${event.input}`);
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
case "tool.error": {
|
|
150
|
+
const dur = event.duration ? ` (${(event.duration / 1e3).toFixed(1)}s)` : "";
|
|
151
|
+
console.error(`${prefix} tool:error ${event.tool}${dur} — ${event.input} — ${event.error}`);
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
case "text": {
|
|
155
|
+
const lines = ((textBuffers.get(event.sessionId) ?? "") + event.text).split("\n");
|
|
156
|
+
textBuffers.set(event.sessionId, lines.pop() ?? "");
|
|
157
|
+
for (const line of lines) console.error(`${prefix} > ${line}`);
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
case "status":
|
|
161
|
+
flushTextBuffer(textBuffers, event.sessionId, sessionName);
|
|
162
|
+
if (event.message) console.error(`${prefix} status:${event.status} — ${event.message}`);
|
|
163
|
+
else console.error(`${prefix} status:${event.status}`);
|
|
164
|
+
break;
|
|
165
|
+
case "step.start":
|
|
166
|
+
console.error(`${prefix} step:start`);
|
|
167
|
+
break;
|
|
168
|
+
case "step.finish": {
|
|
169
|
+
const tokens = `${event.tokens.input} in / ${event.tokens.output} out`;
|
|
170
|
+
const cost = event.cost > 0 ? `, $${event.cost.toFixed(4)}` : "";
|
|
171
|
+
console.error(`${prefix} step:finish — ${tokens}${cost} — ${event.reason}`);
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
case "error":
|
|
175
|
+
console.error(`${prefix} ERROR: ${event.message}`);
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Flush any remaining buffered text for a session.
|
|
181
|
+
*/
|
|
182
|
+
function flushTextBuffer(textBuffers, sessionId, sessionName) {
|
|
183
|
+
const remaining = textBuffers.get(sessionId);
|
|
184
|
+
if (remaining) {
|
|
185
|
+
const prefix = `[opencode] (${sessionName})`;
|
|
186
|
+
console.error(`${prefix} > ${remaining}`);
|
|
187
|
+
textBuffers.set(sessionId, "");
|
|
188
|
+
}
|
|
189
|
+
}
|
|
50
190
|
async function run() {
|
|
51
|
-
const { workflowPath, argsJson, branch } = parseArgs(process.argv.slice(2));
|
|
191
|
+
const { workflowPath, argsJson, branch, model: modelStr } = parseArgs(process.argv.slice(2));
|
|
52
192
|
const workdir = process.cwd();
|
|
53
193
|
let startedOpenCode = null;
|
|
54
194
|
if (branch) execFileSync("git", [
|
|
@@ -74,11 +214,16 @@ async function run() {
|
|
|
74
214
|
process.exit(1);
|
|
75
215
|
}
|
|
76
216
|
}
|
|
217
|
+
await preflight(workdir, modelStr);
|
|
218
|
+
const eventStream = startEventStream(workdir);
|
|
219
|
+
eventStreamAbort = eventStream;
|
|
220
|
+
const model = modelStr ? parseModel(modelStr) : void 0;
|
|
77
221
|
const flue = new Flue({
|
|
78
222
|
workdir,
|
|
79
223
|
args,
|
|
80
224
|
branch,
|
|
81
|
-
secrets: process.env
|
|
225
|
+
secrets: process.env,
|
|
226
|
+
model
|
|
82
227
|
});
|
|
83
228
|
try {
|
|
84
229
|
const workflow = await import(workflowUrl);
|
|
@@ -91,12 +236,35 @@ async function run() {
|
|
|
91
236
|
console.error(error instanceof Error ? error.message : String(error));
|
|
92
237
|
process.exit(1);
|
|
93
238
|
} finally {
|
|
239
|
+
eventStream.abort();
|
|
240
|
+
eventStreamAbort = null;
|
|
94
241
|
await flue.close();
|
|
95
242
|
if (startedOpenCode) stopOpenCodeServer(startedOpenCode);
|
|
96
243
|
openCodeProcess = null;
|
|
97
244
|
}
|
|
98
245
|
}
|
|
99
246
|
run();
|
|
247
|
+
async function preflight(workdir, modelOverride) {
|
|
248
|
+
const res = await fetch(`${OPENCODE_URL}/config/providers?directory=${encodeURIComponent(workdir)}`);
|
|
249
|
+
if (!res.ok) {
|
|
250
|
+
console.error(`[flue] preflight: failed to fetch providers (HTTP ${res.status})`);
|
|
251
|
+
process.exit(1);
|
|
252
|
+
}
|
|
253
|
+
if (((await res.json()).providers || []).length === 0) {
|
|
254
|
+
console.error("[flue] Error: No LLM providers configured.\n\nOpenCode needs at least one provider with an API key to run workflows.\n\n - Set an API key env var (e.g. ANTHROPIC_API_KEY)\n - Or run \"opencode auth login\" to configure a provider\n");
|
|
255
|
+
process.exit(1);
|
|
256
|
+
}
|
|
257
|
+
if (modelOverride) return;
|
|
258
|
+
const configRes = await fetch(`${OPENCODE_URL}/config?directory=${encodeURIComponent(workdir)}`);
|
|
259
|
+
if (!configRes.ok) {
|
|
260
|
+
console.error(`[flue] preflight: failed to fetch config (HTTP ${configRes.status})`);
|
|
261
|
+
process.exit(1);
|
|
262
|
+
}
|
|
263
|
+
if (!(await configRes.json()).model) {
|
|
264
|
+
console.error("[flue] Error: No default model configured.\n\nOpenCode needs a default model to run workflows. Either:\n\n - Pass --model to the flue CLI:\n flue run workflow.ts --model anthropic/claude-sonnet-4-5\n\n - Or set \"model\" in your project's opencode.json:\n { \"model\": \"anthropic/claude-sonnet-4-5\" }\n");
|
|
265
|
+
process.exit(1);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
100
268
|
async function isOpenCodeRunning() {
|
|
101
269
|
try {
|
|
102
270
|
const controller = new AbortController();
|
|
@@ -132,10 +300,12 @@ function stopOpenCodeServer(child) {
|
|
|
132
300
|
child.kill("SIGTERM");
|
|
133
301
|
}
|
|
134
302
|
process.on("SIGINT", () => {
|
|
303
|
+
if (eventStreamAbort) eventStreamAbort.abort();
|
|
135
304
|
if (openCodeProcess) stopOpenCodeServer(openCodeProcess);
|
|
136
305
|
process.exit(130);
|
|
137
306
|
});
|
|
138
307
|
process.on("SIGTERM", () => {
|
|
308
|
+
if (eventStreamAbort) eventStreamAbort.abort();
|
|
139
309
|
if (openCodeProcess) stopOpenCodeServer(openCodeProcess);
|
|
140
310
|
process.exit(143);
|
|
141
311
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flue/cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"flue": "dist/flue.js"
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"check:types": "tsc --noEmit"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@flue/client": "^0.0.
|
|
16
|
+
"@flue/client": "^0.0.6"
|
|
17
17
|
},
|
|
18
18
|
"devDependencies": {
|
|
19
19
|
"tsdown": "^0.20.3"
|