@flue/cli 0.0.7 → 0.0.9
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 +150 -0
- package/package.json +2 -2
package/dist/flue.js
CHANGED
|
@@ -6,6 +6,7 @@ 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
11
|
console.error("Usage: flue run <workflowPath> [--args <json>] [--branch <name>] [--model <provider/model>]");
|
|
11
12
|
}
|
|
@@ -70,6 +71,122 @@ function parseModel(modelStr) {
|
|
|
70
71
|
modelID: modelStr.slice(slashIndex + 1)
|
|
71
72
|
};
|
|
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
|
+
}
|
|
73
190
|
async function run() {
|
|
74
191
|
const { workflowPath, argsJson, branch, model: modelStr } = parseArgs(process.argv.slice(2));
|
|
75
192
|
const workdir = process.cwd();
|
|
@@ -98,6 +215,9 @@ async function run() {
|
|
|
98
215
|
}
|
|
99
216
|
}
|
|
100
217
|
await preflight(workdir, modelStr);
|
|
218
|
+
await setPermissions(workdir);
|
|
219
|
+
const eventStream = startEventStream(workdir);
|
|
220
|
+
eventStreamAbort = eventStream;
|
|
101
221
|
const model = modelStr ? parseModel(modelStr) : void 0;
|
|
102
222
|
const flue = new Flue({
|
|
103
223
|
workdir,
|
|
@@ -117,6 +237,8 @@ async function run() {
|
|
|
117
237
|
console.error(error instanceof Error ? error.message : String(error));
|
|
118
238
|
process.exit(1);
|
|
119
239
|
} finally {
|
|
240
|
+
eventStream.abort();
|
|
241
|
+
eventStreamAbort = null;
|
|
120
242
|
await flue.close();
|
|
121
243
|
if (startedOpenCode) stopOpenCodeServer(startedOpenCode);
|
|
122
244
|
openCodeProcess = null;
|
|
@@ -144,6 +266,32 @@ async function preflight(workdir, modelOverride) {
|
|
|
144
266
|
process.exit(1);
|
|
145
267
|
}
|
|
146
268
|
}
|
|
269
|
+
/**
|
|
270
|
+
* Configure OpenCode to auto-approve all tool operations.
|
|
271
|
+
* In headless/CI mode there's no human to respond to permission prompts,
|
|
272
|
+
* so we must allow everything upfront. This mirrors what the Cloudflare
|
|
273
|
+
* runner does via `'*': 'allow'` in its config.
|
|
274
|
+
*
|
|
275
|
+
* NOTE: We set each permission field explicitly here. The OpenCode config
|
|
276
|
+
* also supports a wildcard `'*': 'allow'` key (used by the Cloudflare
|
|
277
|
+
* runner with @ts-expect-error), but the typed Config schema doesn't
|
|
278
|
+
* expose it. We should try wildcard support at some point to future-proof
|
|
279
|
+
* against new permission types being added.
|
|
280
|
+
*/
|
|
281
|
+
async function setPermissions(workdir) {
|
|
282
|
+
const res = await fetch(`${OPENCODE_URL}/config?directory=${encodeURIComponent(workdir)}`, {
|
|
283
|
+
method: "PUT",
|
|
284
|
+
headers: { "Content-Type": "application/json" },
|
|
285
|
+
body: JSON.stringify({ permission: {
|
|
286
|
+
edit: "allow",
|
|
287
|
+
bash: "allow",
|
|
288
|
+
webfetch: "allow",
|
|
289
|
+
doom_loop: "allow",
|
|
290
|
+
external_directory: "allow"
|
|
291
|
+
} })
|
|
292
|
+
});
|
|
293
|
+
if (!res.ok) console.error(`[flue] warning: failed to set permissions (HTTP ${res.status})`);
|
|
294
|
+
}
|
|
147
295
|
async function isOpenCodeRunning() {
|
|
148
296
|
try {
|
|
149
297
|
const controller = new AbortController();
|
|
@@ -179,10 +327,12 @@ function stopOpenCodeServer(child) {
|
|
|
179
327
|
child.kill("SIGTERM");
|
|
180
328
|
}
|
|
181
329
|
process.on("SIGINT", () => {
|
|
330
|
+
if (eventStreamAbort) eventStreamAbort.abort();
|
|
182
331
|
if (openCodeProcess) stopOpenCodeServer(openCodeProcess);
|
|
183
332
|
process.exit(130);
|
|
184
333
|
});
|
|
185
334
|
process.on("SIGTERM", () => {
|
|
335
|
+
if (eventStreamAbort) eventStreamAbort.abort();
|
|
186
336
|
if (openCodeProcess) stopOpenCodeServer(openCodeProcess);
|
|
187
337
|
process.exit(143);
|
|
188
338
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flue/cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.9",
|
|
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"
|