@flumecode/runner 0.22.0 → 0.23.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/dist/cli.js +1007 -37
- package/package.json +7 -1
- package/skills-plugin/skills/preview-ui/SKILL.md +127 -0
package/dist/cli.js
CHANGED
|
@@ -1,4 +1,652 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __esm = (fn, res) => function __init() {
|
|
5
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
6
|
+
};
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// src/version.ts
|
|
13
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
function readVersion() {
|
|
16
|
+
try {
|
|
17
|
+
const path = fileURLToPath(new URL("../package.json", import.meta.url));
|
|
18
|
+
const pkg = JSON.parse(readFileSync2(path, "utf8"));
|
|
19
|
+
return pkg.version ?? "unknown";
|
|
20
|
+
} catch {
|
|
21
|
+
return "unknown";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function compareVersions(a, b) {
|
|
25
|
+
const parse = (v) => (v.split("-")[0] ?? v).split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
26
|
+
const pa = parse(a);
|
|
27
|
+
const pb = parse(b);
|
|
28
|
+
const len = Math.max(pa.length, pb.length);
|
|
29
|
+
for (let i = 0; i < len; i++) {
|
|
30
|
+
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
31
|
+
if (diff !== 0) return diff;
|
|
32
|
+
}
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
var RUNNER_VERSION, RUNNER_VERSION_HEADER, RUNNER_MIN_VERSION_HEADER, RUNNER_LATEST_VERSION_HEADER;
|
|
36
|
+
var init_version = __esm({
|
|
37
|
+
"src/version.ts"() {
|
|
38
|
+
"use strict";
|
|
39
|
+
RUNNER_VERSION = readVersion();
|
|
40
|
+
RUNNER_VERSION_HEADER = "x-flumecode-runner-version";
|
|
41
|
+
RUNNER_MIN_VERSION_HEADER = "x-flumecode-min-runner-version";
|
|
42
|
+
RUNNER_LATEST_VERSION_HEADER = "x-flumecode-latest-runner-version";
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// src/tui-api.ts
|
|
47
|
+
function authHeaders(cfg) {
|
|
48
|
+
return {
|
|
49
|
+
Authorization: `Bearer ${cfg.apiToken ?? ""}`,
|
|
50
|
+
"x-flumecode-runner-version": RUNNER_VERSION
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
async function safeText2(res) {
|
|
54
|
+
try {
|
|
55
|
+
return await res.text();
|
|
56
|
+
} catch {
|
|
57
|
+
return "";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async function get(cfg, path) {
|
|
61
|
+
const res = await fetch(cfg.serverUrl + path, { headers: authHeaders(cfg) });
|
|
62
|
+
if (!res.ok) throw new Error(await safeText2(res));
|
|
63
|
+
return res.json();
|
|
64
|
+
}
|
|
65
|
+
async function post(cfg, path, body) {
|
|
66
|
+
const res = await fetch(cfg.serverUrl + path, {
|
|
67
|
+
method: "POST",
|
|
68
|
+
headers: { ...authHeaders(cfg), "Content-Type": "application/json" },
|
|
69
|
+
body: JSON.stringify(body)
|
|
70
|
+
});
|
|
71
|
+
if (!res.ok) throw new Error(await safeText2(res));
|
|
72
|
+
return res.json();
|
|
73
|
+
}
|
|
74
|
+
var listRepositories, listAgents, listRequests, getRequest, createRequest, answerWidgets, acceptPlan, startImplementing, listJobs, listRunners;
|
|
75
|
+
var init_tui_api = __esm({
|
|
76
|
+
"src/tui-api.ts"() {
|
|
77
|
+
"use strict";
|
|
78
|
+
init_version();
|
|
79
|
+
listRepositories = (cfg) => get(cfg, "/api/cli/repositories").then((r) => r.repositories);
|
|
80
|
+
listAgents = (cfg) => get(cfg, "/api/cli/agents").then((r) => r.agents);
|
|
81
|
+
listRequests = (cfg, repoId) => get(cfg, `/api/cli/repositories/${repoId}/requests`).then(
|
|
82
|
+
(r) => r.requests
|
|
83
|
+
);
|
|
84
|
+
getRequest = (cfg, id) => get(cfg, `/api/cli/requests/${id}`);
|
|
85
|
+
createRequest = (cfg, input) => post(cfg, "/api/cli/requests", input);
|
|
86
|
+
answerWidgets = (cfg, messageId, payload) => post(cfg, `/api/cli/messages/${messageId}/answer`, payload);
|
|
87
|
+
acceptPlan = (cfg, messageId, target, mergeBranch) => post(cfg, `/api/cli/plans/${messageId}/accept`, { target, mergeBranch });
|
|
88
|
+
startImplementing = (cfg, sessionId) => post(cfg, `/api/cli/sessions/${sessionId}/implement`, {});
|
|
89
|
+
listJobs = (cfg) => get(cfg, "/api/cli/jobs").then((r) => r.jobs);
|
|
90
|
+
listRunners = (cfg) => get(cfg, "/api/cli/runners").then((r) => r.runners);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// src/tui/usePoll.ts
|
|
95
|
+
import { useState, useEffect, useRef } from "react";
|
|
96
|
+
function usePoll(fetcher, intervalMs) {
|
|
97
|
+
const [data, setData] = useState(null);
|
|
98
|
+
const fetcherRef = useRef(fetcher);
|
|
99
|
+
fetcherRef.current = fetcher;
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
let cancelled = false;
|
|
102
|
+
function poll() {
|
|
103
|
+
fetcherRef.current().then((result) => {
|
|
104
|
+
if (!cancelled) setData(result);
|
|
105
|
+
}).catch(() => {
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
poll();
|
|
109
|
+
const id = setInterval(poll, intervalMs);
|
|
110
|
+
return () => {
|
|
111
|
+
cancelled = true;
|
|
112
|
+
clearInterval(id);
|
|
113
|
+
};
|
|
114
|
+
}, [intervalMs]);
|
|
115
|
+
return data;
|
|
116
|
+
}
|
|
117
|
+
var init_usePoll = __esm({
|
|
118
|
+
"src/tui/usePoll.ts"() {
|
|
119
|
+
"use strict";
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// src/tui/StatusBar.tsx
|
|
124
|
+
import { Box, Text } from "ink";
|
|
125
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
126
|
+
function StatusBar({ jobs, runners }) {
|
|
127
|
+
const runningJobs = jobs?.filter((j) => j.status === "running").length ?? 0;
|
|
128
|
+
const onlineRunners = runners?.filter((r) => r.status === "online").length ?? 0;
|
|
129
|
+
return /* @__PURE__ */ jsx(Box, { borderStyle: "single", paddingX: 1, children: /* @__PURE__ */ jsxs(Text, { dimColor: true, children: [
|
|
130
|
+
"Jobs running: ",
|
|
131
|
+
runningJobs,
|
|
132
|
+
" | Runners online: ",
|
|
133
|
+
onlineRunners
|
|
134
|
+
] }) });
|
|
135
|
+
}
|
|
136
|
+
var init_StatusBar = __esm({
|
|
137
|
+
"src/tui/StatusBar.tsx"() {
|
|
138
|
+
"use strict";
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// src/tui/RepoListScreen.tsx
|
|
143
|
+
import { useState as useState2 } from "react";
|
|
144
|
+
import { Box as Box2, Text as Text2, useInput } from "ink";
|
|
145
|
+
import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
146
|
+
function RepoListScreen({ config, onSelect }) {
|
|
147
|
+
const repos = usePoll(() => listRepositories(config), 1e4);
|
|
148
|
+
const [cursor, setCursor] = useState2(0);
|
|
149
|
+
useInput((input, key) => {
|
|
150
|
+
if (!repos) return;
|
|
151
|
+
if (key.upArrow) setCursor((c) => Math.max(0, c - 1));
|
|
152
|
+
else if (key.downArrow) setCursor((c) => Math.min(repos.length - 1, c + 1));
|
|
153
|
+
else if (key.return) {
|
|
154
|
+
const repo = repos[cursor];
|
|
155
|
+
if (repo) onSelect(repo);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
if (!repos) return /* @__PURE__ */ jsx2(Text2, { children: "Loading repositories\u2026" });
|
|
159
|
+
if (repos.length === 0) return /* @__PURE__ */ jsx2(Text2, { children: "No repositories found." });
|
|
160
|
+
return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", children: [
|
|
161
|
+
/* @__PURE__ */ jsx2(Text2, { bold: true, children: "Select a repository (\u2191\u2193 Enter):" }),
|
|
162
|
+
repos.map((repo, i) => /* @__PURE__ */ jsxs2(Text2, { color: i === cursor ? "cyan" : void 0, children: [
|
|
163
|
+
i === cursor ? "\u25B6 " : " ",
|
|
164
|
+
repo.fullName
|
|
165
|
+
] }, repo.id))
|
|
166
|
+
] });
|
|
167
|
+
}
|
|
168
|
+
var init_RepoListScreen = __esm({
|
|
169
|
+
"src/tui/RepoListScreen.tsx"() {
|
|
170
|
+
"use strict";
|
|
171
|
+
init_usePoll();
|
|
172
|
+
init_tui_api();
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// src/tui/RequestListScreen.tsx
|
|
177
|
+
import { useState as useState3 } from "react";
|
|
178
|
+
import { Box as Box3, Text as Text3, useInput as useInput2 } from "ink";
|
|
179
|
+
import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
180
|
+
function RequestListScreen({ config, repo, onOpen, onNew, onBack }) {
|
|
181
|
+
const requests = usePoll(() => listRequests(config, repo.id), 5e3);
|
|
182
|
+
const [cursor, setCursor] = useState3(0);
|
|
183
|
+
useInput2((input, key) => {
|
|
184
|
+
if (key.escape) {
|
|
185
|
+
onBack();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (!requests) return;
|
|
189
|
+
if (key.upArrow) setCursor((c) => Math.max(0, c - 1));
|
|
190
|
+
else if (key.downArrow) setCursor((c) => Math.min(requests.length - 1, c + 1));
|
|
191
|
+
else if (key.return) {
|
|
192
|
+
const req = requests[cursor];
|
|
193
|
+
if (req) onOpen(req);
|
|
194
|
+
} else if (input === "n") {
|
|
195
|
+
onNew();
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
if (!requests) return /* @__PURE__ */ jsx3(Text3, { children: "Loading requests\u2026" });
|
|
199
|
+
return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", children: [
|
|
200
|
+
/* @__PURE__ */ jsxs3(Text3, { bold: true, children: [
|
|
201
|
+
repo.fullName,
|
|
202
|
+
" \u2014 Requests (\u2191\u2193 Enter open, n new, Esc back):"
|
|
203
|
+
] }),
|
|
204
|
+
requests.length === 0 && /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "No requests yet." }),
|
|
205
|
+
requests.map((req, i) => /* @__PURE__ */ jsxs3(Text3, { color: i === cursor ? "cyan" : void 0, children: [
|
|
206
|
+
i === cursor ? "\u25B6 " : " ",
|
|
207
|
+
"[",
|
|
208
|
+
req.status,
|
|
209
|
+
"] ",
|
|
210
|
+
req.title
|
|
211
|
+
] }, req.id))
|
|
212
|
+
] });
|
|
213
|
+
}
|
|
214
|
+
var init_RequestListScreen = __esm({
|
|
215
|
+
"src/tui/RequestListScreen.tsx"() {
|
|
216
|
+
"use strict";
|
|
217
|
+
init_usePoll();
|
|
218
|
+
init_tui_api();
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// src/tui/NewRequestScreen.tsx
|
|
223
|
+
import { useState as useState4 } from "react";
|
|
224
|
+
import { Box as Box4, Text as Text4, useInput as useInput3 } from "ink";
|
|
225
|
+
import TextInput from "ink-text-input";
|
|
226
|
+
import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
227
|
+
function NewRequestScreen({ config, repo, onCreated, onBack }) {
|
|
228
|
+
const agents = usePoll(() => listAgents(config), 3e4);
|
|
229
|
+
const [field, setField] = useState4("title");
|
|
230
|
+
const [title, setTitle] = useState4("");
|
|
231
|
+
const [body, setBody] = useState4("");
|
|
232
|
+
const [agentCursor, setAgentCursor] = useState4(0);
|
|
233
|
+
const [branch, setBranch] = useState4("");
|
|
234
|
+
const [error, setError] = useState4(null);
|
|
235
|
+
const [submitting, setSubmitting] = useState4(false);
|
|
236
|
+
useInput3((_input, key) => {
|
|
237
|
+
if (key.escape) {
|
|
238
|
+
onBack();
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (field === "title" && key.tab) {
|
|
242
|
+
setField("body");
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (field === "body" && key.tab) {
|
|
246
|
+
setField("agent");
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (field === "agent") {
|
|
250
|
+
if (key.upArrow && agents) setAgentCursor((c) => Math.max(0, c - 1));
|
|
251
|
+
if (key.downArrow && agents) setAgentCursor((c) => Math.min((agents.length || 1) - 1, c + 1));
|
|
252
|
+
if (key.tab) setField("branch");
|
|
253
|
+
}
|
|
254
|
+
if (field === "branch" && key.return && !submitting) {
|
|
255
|
+
void submit();
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
async function submit() {
|
|
259
|
+
if (!title.trim() || !agents?.[agentCursor]) return;
|
|
260
|
+
setSubmitting(true);
|
|
261
|
+
setError(null);
|
|
262
|
+
try {
|
|
263
|
+
const result = await createRequest(config, {
|
|
264
|
+
title: title.trim(),
|
|
265
|
+
body: body.trim(),
|
|
266
|
+
agentId: agents[agentCursor].id,
|
|
267
|
+
repoId: repo.id,
|
|
268
|
+
checkoutBranch: branch.trim() || void 0
|
|
269
|
+
});
|
|
270
|
+
onCreated(result.requestId);
|
|
271
|
+
} catch (err) {
|
|
272
|
+
setError(err instanceof Error ? err.message : "Unknown error");
|
|
273
|
+
setSubmitting(false);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", gap: 1, children: [
|
|
277
|
+
/* @__PURE__ */ jsxs4(Text4, { bold: true, children: [
|
|
278
|
+
"New Request in ",
|
|
279
|
+
repo.fullName,
|
|
280
|
+
" (Tab to advance, Esc back)"
|
|
281
|
+
] }),
|
|
282
|
+
/* @__PURE__ */ jsxs4(Box4, { children: [
|
|
283
|
+
/* @__PURE__ */ jsx4(Text4, { children: "Title: " }),
|
|
284
|
+
/* @__PURE__ */ jsx4(TextInput, { value: title, onChange: setTitle, focus: field === "title" })
|
|
285
|
+
] }),
|
|
286
|
+
/* @__PURE__ */ jsxs4(Box4, { children: [
|
|
287
|
+
/* @__PURE__ */ jsx4(Text4, { children: "Body: " }),
|
|
288
|
+
/* @__PURE__ */ jsx4(TextInput, { value: body, onChange: setBody, focus: field === "body" })
|
|
289
|
+
] }),
|
|
290
|
+
/* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", children: [
|
|
291
|
+
/* @__PURE__ */ jsxs4(Text4, { children: [
|
|
292
|
+
"Agent",
|
|
293
|
+
field === "agent" ? " (\u2191\u2193 to pick, Tab next)" : "",
|
|
294
|
+
":"
|
|
295
|
+
] }),
|
|
296
|
+
(agents ?? []).map((a, i) => /* @__PURE__ */ jsxs4(Text4, { color: field === "agent" && i === agentCursor ? "cyan" : void 0, children: [
|
|
297
|
+
field === "agent" && i === agentCursor ? "\u25B6 " : " ",
|
|
298
|
+
a.name
|
|
299
|
+
] }, a.id))
|
|
300
|
+
] }),
|
|
301
|
+
/* @__PURE__ */ jsxs4(Box4, { children: [
|
|
302
|
+
/* @__PURE__ */ jsx4(Text4, { children: "Checkout branch (optional): " }),
|
|
303
|
+
/* @__PURE__ */ jsx4(TextInput, { value: branch, onChange: setBranch, focus: field === "branch" })
|
|
304
|
+
] }),
|
|
305
|
+
submitting && /* @__PURE__ */ jsx4(Text4, { color: "yellow", children: "Creating\u2026" }),
|
|
306
|
+
error && /* @__PURE__ */ jsxs4(Text4, { color: "red", children: [
|
|
307
|
+
"Error: ",
|
|
308
|
+
error
|
|
309
|
+
] })
|
|
310
|
+
] });
|
|
311
|
+
}
|
|
312
|
+
var init_NewRequestScreen = __esm({
|
|
313
|
+
"src/tui/NewRequestScreen.tsx"() {
|
|
314
|
+
"use strict";
|
|
315
|
+
init_usePoll();
|
|
316
|
+
init_tui_api();
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// src/tui/WidgetAnswerScreen.tsx
|
|
321
|
+
import { useState as useState5 } from "react";
|
|
322
|
+
import { Box as Box5, Text as Text5, useInput as useInput4 } from "ink";
|
|
323
|
+
import TextInput2 from "ink-text-input";
|
|
324
|
+
import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
|
|
325
|
+
function renderOptionLabel(opt, i, cursor, isSingle, checked) {
|
|
326
|
+
const pointer = i === cursor ? "\u25B6 " : " ";
|
|
327
|
+
if (isSingle) {
|
|
328
|
+
return `${pointer}${opt.label}`;
|
|
329
|
+
}
|
|
330
|
+
return `${pointer}[${checked.has(opt.id) ? "x" : " "}] ${opt.label}`;
|
|
331
|
+
}
|
|
332
|
+
function WidgetAnswerScreen({ config, message, onDone }) {
|
|
333
|
+
const [widgetIndex, setWidgetIndex] = useState5(0);
|
|
334
|
+
const [cursor, setCursor] = useState5(0);
|
|
335
|
+
const [checked, setChecked] = useState5(/* @__PURE__ */ new Set());
|
|
336
|
+
const [customText, setCustomText] = useState5("");
|
|
337
|
+
const [showCustom, setShowCustom] = useState5(false);
|
|
338
|
+
const [error, setError] = useState5(null);
|
|
339
|
+
const [submitting, setSubmitting] = useState5(false);
|
|
340
|
+
const widget = message.widgets[widgetIndex];
|
|
341
|
+
function handleSingleInput(input, key, w) {
|
|
342
|
+
const opts2 = [...w.options, { id: "__other__", label: "Other" }];
|
|
343
|
+
if (key.upArrow) {
|
|
344
|
+
setCursor((c) => Math.max(0, c - 1));
|
|
345
|
+
} else if (key.downArrow) {
|
|
346
|
+
setCursor((c) => Math.min(opts2.length - 1, c + 1));
|
|
347
|
+
} else if (key.return) {
|
|
348
|
+
const picked = opts2[cursor];
|
|
349
|
+
if (picked?.id === "__other__") {
|
|
350
|
+
setShowCustom(true);
|
|
351
|
+
} else {
|
|
352
|
+
void advanceOrSubmit(w, picked?.id, void 0, void 0);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
function handleMultiInput(input, key, w) {
|
|
357
|
+
const opts2 = [...w.options, { id: "__other__", label: "Other" }];
|
|
358
|
+
if (key.upArrow) {
|
|
359
|
+
setCursor((c) => Math.max(0, c - 1));
|
|
360
|
+
} else if (key.downArrow) {
|
|
361
|
+
setCursor((c) => Math.min(opts2.length - 1, c + 1));
|
|
362
|
+
} else if (input === " ") {
|
|
363
|
+
const picked = opts2[cursor];
|
|
364
|
+
if (!picked) return;
|
|
365
|
+
if (picked.id === "__other__") {
|
|
366
|
+
setShowCustom((s) => !s);
|
|
367
|
+
} else {
|
|
368
|
+
const next = new Set(checked);
|
|
369
|
+
if (next.has(picked.id)) next.delete(picked.id);
|
|
370
|
+
else next.add(picked.id);
|
|
371
|
+
setChecked(next);
|
|
372
|
+
}
|
|
373
|
+
} else if (key.return) {
|
|
374
|
+
void advanceOrSubmit(w, void 0, [...checked], customText || void 0);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
useInput4((input, key) => {
|
|
378
|
+
if (!widget) return;
|
|
379
|
+
if (widget.type === "single_select") {
|
|
380
|
+
handleSingleInput(input, key, widget);
|
|
381
|
+
} else {
|
|
382
|
+
handleMultiInput(input, key, widget);
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
async function advanceOrSubmit(w, optionId, optionIds, custom) {
|
|
386
|
+
const answers = [
|
|
387
|
+
{
|
|
388
|
+
widgetId: w.id,
|
|
389
|
+
...optionId !== void 0 ? { optionId } : {},
|
|
390
|
+
...optionIds !== void 0 ? { optionIds } : {},
|
|
391
|
+
...custom ? { customText: custom } : {}
|
|
392
|
+
}
|
|
393
|
+
];
|
|
394
|
+
if (widgetIndex < message.widgets.length - 1) {
|
|
395
|
+
setWidgetIndex((i) => i + 1);
|
|
396
|
+
setCursor(0);
|
|
397
|
+
setChecked(/* @__PURE__ */ new Set());
|
|
398
|
+
setCustomText("");
|
|
399
|
+
setShowCustom(false);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
setSubmitting(true);
|
|
403
|
+
setError(null);
|
|
404
|
+
try {
|
|
405
|
+
await answerWidgets(config, message.id, { requestId: message.requestId, answers });
|
|
406
|
+
onDone();
|
|
407
|
+
} catch (err) {
|
|
408
|
+
setError(err instanceof Error ? err.message : "Unknown error");
|
|
409
|
+
setSubmitting(false);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (!widget) return /* @__PURE__ */ jsx5(Text5, { children: "No widgets to answer." });
|
|
413
|
+
const isSingle = widget.type === "single_select";
|
|
414
|
+
const opts = [...widget.options, { id: "__other__", label: "Other" }];
|
|
415
|
+
return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", gap: 1, children: [
|
|
416
|
+
/* @__PURE__ */ jsxs5(Text5, { bold: true, children: [
|
|
417
|
+
"Widget ",
|
|
418
|
+
widgetIndex + 1,
|
|
419
|
+
"/",
|
|
420
|
+
message.widgets.length,
|
|
421
|
+
": ",
|
|
422
|
+
widget.question
|
|
423
|
+
] }),
|
|
424
|
+
widget.body && /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: widget.body }),
|
|
425
|
+
/* @__PURE__ */ jsx5(Text5, { dimColor: true, children: isSingle ? "\u2191\u2193 to navigate, Enter to select" : "\u2191\u2193 to navigate, Space to toggle, Enter to confirm" }),
|
|
426
|
+
opts.map((opt, i) => /* @__PURE__ */ jsx5(Text5, { color: i === cursor ? "cyan" : void 0, children: renderOptionLabel(opt, i, cursor, isSingle, checked) }, opt.id)),
|
|
427
|
+
showCustom && /* @__PURE__ */ jsxs5(Box5, { children: [
|
|
428
|
+
/* @__PURE__ */ jsx5(Text5, { children: "Custom answer: " }),
|
|
429
|
+
/* @__PURE__ */ jsx5(TextInput2, { value: customText, onChange: setCustomText, focus: showCustom })
|
|
430
|
+
] }),
|
|
431
|
+
submitting && /* @__PURE__ */ jsx5(Text5, { color: "yellow", children: "Submitting\u2026" }),
|
|
432
|
+
error && /* @__PURE__ */ jsxs5(Text5, { color: "red", children: [
|
|
433
|
+
"Error: ",
|
|
434
|
+
error
|
|
435
|
+
] })
|
|
436
|
+
] });
|
|
437
|
+
}
|
|
438
|
+
var init_WidgetAnswerScreen = __esm({
|
|
439
|
+
"src/tui/WidgetAnswerScreen.tsx"() {
|
|
440
|
+
"use strict";
|
|
441
|
+
init_tui_api();
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// src/tui/ThreadScreen.tsx
|
|
446
|
+
import { useState as useState6 } from "react";
|
|
447
|
+
import { Box as Box6, Text as Text6, useInput as useInput5 } from "ink";
|
|
448
|
+
import TextInput3 from "ink-text-input";
|
|
449
|
+
import { Fragment, jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
|
|
450
|
+
function ThreadScreen({ config, requestId, onBack }) {
|
|
451
|
+
const data = usePoll(() => getRequest(config, requestId), 3e3);
|
|
452
|
+
const [cursor, setCursor] = useState6(0);
|
|
453
|
+
const [overlay, setOverlay] = useState6({ kind: "none" });
|
|
454
|
+
const [targetBranch, setTargetBranch] = useState6("main");
|
|
455
|
+
const [mergeBranch, setMergeBranch] = useState6("");
|
|
456
|
+
const [acceptStep, setAcceptStep] = useState6("target");
|
|
457
|
+
const [error, setError] = useState6(null);
|
|
458
|
+
const messages = data?.messages ?? [];
|
|
459
|
+
const sessions = data?.sessions ?? [];
|
|
460
|
+
const selectedMsg = messages[cursor];
|
|
461
|
+
const implementableSession = sessions.find((s) => s.status === "planned" || s.status === "ready");
|
|
462
|
+
useInput5((_input, key) => {
|
|
463
|
+
if (overlay.kind !== "none") return;
|
|
464
|
+
if (key.escape) {
|
|
465
|
+
onBack();
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (key.upArrow) {
|
|
469
|
+
setCursor((c) => Math.max(0, c - 1));
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
if (key.downArrow) {
|
|
473
|
+
setCursor((c) => Math.min(messages.length - 1, c + 1));
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (key.return && selectedMsg) {
|
|
477
|
+
if (selectedMsg.widgets?.length) {
|
|
478
|
+
const widgets = selectedMsg.widgets;
|
|
479
|
+
setOverlay({ kind: "widget", message: { ...selectedMsg, widgets, requestId } });
|
|
480
|
+
} else if (selectedMsg.type === "plan") {
|
|
481
|
+
setTargetBranch("main");
|
|
482
|
+
setMergeBranch("");
|
|
483
|
+
setAcceptStep("target");
|
|
484
|
+
setOverlay({ kind: "accept", messageId: selectedMsg.id });
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
if (_input === "i" && implementableSession) {
|
|
488
|
+
void doImplement(implementableSession.id);
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
async function doAcceptPlan() {
|
|
492
|
+
if (overlay.kind !== "accept") return;
|
|
493
|
+
try {
|
|
494
|
+
await acceptPlan(
|
|
495
|
+
config,
|
|
496
|
+
overlay.messageId,
|
|
497
|
+
targetBranch.trim() || "main",
|
|
498
|
+
mergeBranch.trim() || "flumecode/session"
|
|
499
|
+
);
|
|
500
|
+
setOverlay({ kind: "accepted" });
|
|
501
|
+
} catch (err) {
|
|
502
|
+
setError(err instanceof Error ? err.message : "Unknown error");
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
async function doImplement(sessionId) {
|
|
506
|
+
try {
|
|
507
|
+
await startImplementing(config, sessionId);
|
|
508
|
+
} catch (err) {
|
|
509
|
+
setError(err instanceof Error ? err.message : "Unknown error");
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
if (overlay.kind === "widget") {
|
|
513
|
+
return /* @__PURE__ */ jsx6(
|
|
514
|
+
WidgetAnswerScreen,
|
|
515
|
+
{
|
|
516
|
+
config,
|
|
517
|
+
message: overlay.message,
|
|
518
|
+
onDone: () => setOverlay({ kind: "none" })
|
|
519
|
+
}
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
if (overlay.kind === "accept") {
|
|
523
|
+
return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", gap: 1, children: [
|
|
524
|
+
/* @__PURE__ */ jsx6(Text6, { bold: true, children: "Accept Plan" }),
|
|
525
|
+
/* @__PURE__ */ jsx6(Text6, { children: "Enter target branch (base):" }),
|
|
526
|
+
/* @__PURE__ */ jsx6(
|
|
527
|
+
TextInput3,
|
|
528
|
+
{
|
|
529
|
+
value: targetBranch,
|
|
530
|
+
onChange: setTargetBranch,
|
|
531
|
+
onSubmit: () => setAcceptStep("merge"),
|
|
532
|
+
focus: acceptStep === "target"
|
|
533
|
+
}
|
|
534
|
+
),
|
|
535
|
+
acceptStep === "merge" && /* @__PURE__ */ jsxs6(Fragment, { children: [
|
|
536
|
+
/* @__PURE__ */ jsx6(Text6, { children: "Enter merge branch name:" }),
|
|
537
|
+
/* @__PURE__ */ jsx6(
|
|
538
|
+
TextInput3,
|
|
539
|
+
{
|
|
540
|
+
value: mergeBranch,
|
|
541
|
+
onChange: setMergeBranch,
|
|
542
|
+
onSubmit: () => void doAcceptPlan(),
|
|
543
|
+
focus: true
|
|
544
|
+
}
|
|
545
|
+
)
|
|
546
|
+
] }),
|
|
547
|
+
error && /* @__PURE__ */ jsx6(Text6, { color: "red", children: error })
|
|
548
|
+
] });
|
|
549
|
+
}
|
|
550
|
+
return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", children: [
|
|
551
|
+
/* @__PURE__ */ jsx6(Text6, { bold: true, children: "Thread (\u2191\u2193 navigate, Enter act on selected, i implement, Esc back)" }),
|
|
552
|
+
!data && /* @__PURE__ */ jsx6(Text6, { children: "Loading\u2026" }),
|
|
553
|
+
overlay.kind === "accepted" && /* @__PURE__ */ jsx6(Text6, { color: "green", children: "Plan accepted! Job queued." }),
|
|
554
|
+
messages.map((msg, i) => /* @__PURE__ */ jsx6(Box6, { flexDirection: "column", marginY: 0, children: /* @__PURE__ */ jsxs6(Text6, { color: i === cursor ? "cyan" : void 0, children: [
|
|
555
|
+
i === cursor ? "\u25B6 " : " ",
|
|
556
|
+
"[",
|
|
557
|
+
msg.type,
|
|
558
|
+
"] ",
|
|
559
|
+
msg.body?.slice(0, 80) ?? "",
|
|
560
|
+
msg.widgets?.length ? ` (${msg.widgets.length} widget${msg.widgets.length > 1 ? "s" : ""})` : ""
|
|
561
|
+
] }) }, msg.id)),
|
|
562
|
+
implementableSession && /* @__PURE__ */ jsxs6(Text6, { dimColor: true, children: [
|
|
563
|
+
"Press i to start implementing session ",
|
|
564
|
+
implementableSession.id.slice(0, 8),
|
|
565
|
+
"\u2026"
|
|
566
|
+
] }),
|
|
567
|
+
error && /* @__PURE__ */ jsx6(Text6, { color: "red", children: error })
|
|
568
|
+
] });
|
|
569
|
+
}
|
|
570
|
+
var init_ThreadScreen = __esm({
|
|
571
|
+
"src/tui/ThreadScreen.tsx"() {
|
|
572
|
+
"use strict";
|
|
573
|
+
init_usePoll();
|
|
574
|
+
init_tui_api();
|
|
575
|
+
init_WidgetAnswerScreen();
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
// src/tui/App.tsx
|
|
580
|
+
var App_exports = {};
|
|
581
|
+
__export(App_exports, {
|
|
582
|
+
App: () => App
|
|
583
|
+
});
|
|
584
|
+
import { useState as useState7 } from "react";
|
|
585
|
+
import { Box as Box7 } from "ink";
|
|
586
|
+
import { jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
|
|
587
|
+
function renderScreen(screen, setScreen, config) {
|
|
588
|
+
switch (screen.name) {
|
|
589
|
+
case "repos":
|
|
590
|
+
return /* @__PURE__ */ jsx7(
|
|
591
|
+
RepoListScreen,
|
|
592
|
+
{
|
|
593
|
+
config,
|
|
594
|
+
onSelect: (repo) => setScreen({ name: "requests", repo })
|
|
595
|
+
}
|
|
596
|
+
);
|
|
597
|
+
case "requests":
|
|
598
|
+
return /* @__PURE__ */ jsx7(
|
|
599
|
+
RequestListScreen,
|
|
600
|
+
{
|
|
601
|
+
config,
|
|
602
|
+
repo: screen.repo,
|
|
603
|
+
onOpen: (req) => setScreen({ name: "thread", repo: screen.repo, requestId: req.id }),
|
|
604
|
+
onNew: () => setScreen({ name: "new-request", repo: screen.repo }),
|
|
605
|
+
onBack: () => setScreen({ name: "repos" })
|
|
606
|
+
}
|
|
607
|
+
);
|
|
608
|
+
case "new-request":
|
|
609
|
+
return /* @__PURE__ */ jsx7(
|
|
610
|
+
NewRequestScreen,
|
|
611
|
+
{
|
|
612
|
+
config,
|
|
613
|
+
repo: screen.repo,
|
|
614
|
+
onCreated: (requestId) => setScreen({ name: "thread", repo: screen.repo, requestId }),
|
|
615
|
+
onBack: () => setScreen({ name: "requests", repo: screen.repo })
|
|
616
|
+
}
|
|
617
|
+
);
|
|
618
|
+
case "thread":
|
|
619
|
+
return /* @__PURE__ */ jsx7(
|
|
620
|
+
ThreadScreen,
|
|
621
|
+
{
|
|
622
|
+
config,
|
|
623
|
+
requestId: screen.requestId,
|
|
624
|
+
onBack: () => setScreen({ name: "requests", repo: screen.repo })
|
|
625
|
+
}
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
function App({ config }) {
|
|
630
|
+
const [screen, setScreen] = useState7({ name: "repos" });
|
|
631
|
+
const jobs = usePoll(() => listJobs(config), 3e3);
|
|
632
|
+
const runners = usePoll(() => listRunners(config), 3e3);
|
|
633
|
+
return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
|
|
634
|
+
renderScreen(screen, setScreen, config),
|
|
635
|
+
/* @__PURE__ */ jsx7(StatusBar, { jobs, runners })
|
|
636
|
+
] });
|
|
637
|
+
}
|
|
638
|
+
var init_App = __esm({
|
|
639
|
+
"src/tui/App.tsx"() {
|
|
640
|
+
"use strict";
|
|
641
|
+
init_tui_api();
|
|
642
|
+
init_usePoll();
|
|
643
|
+
init_StatusBar();
|
|
644
|
+
init_RepoListScreen();
|
|
645
|
+
init_RequestListScreen();
|
|
646
|
+
init_NewRequestScreen();
|
|
647
|
+
init_ThreadScreen();
|
|
648
|
+
}
|
|
649
|
+
});
|
|
2
650
|
|
|
3
651
|
// src/cli.ts
|
|
4
652
|
import { createInterface } from "node:readline/promises";
|
|
@@ -15,7 +663,9 @@ function readConfig() {
|
|
|
15
663
|
try {
|
|
16
664
|
const parsed = JSON.parse(readFileSync(configPath, "utf8"));
|
|
17
665
|
if (!parsed.serverUrl || !parsed.token) return null;
|
|
18
|
-
|
|
666
|
+
const config = { serverUrl: parsed.serverUrl, token: parsed.token };
|
|
667
|
+
if (parsed.apiToken) config.apiToken = parsed.apiToken;
|
|
668
|
+
return config;
|
|
19
669
|
} catch {
|
|
20
670
|
return null;
|
|
21
671
|
}
|
|
@@ -26,38 +676,11 @@ function writeConfig(config) {
|
|
|
26
676
|
}
|
|
27
677
|
|
|
28
678
|
// src/run.ts
|
|
29
|
-
import { existsSync as
|
|
30
|
-
import { join as
|
|
31
|
-
|
|
32
|
-
// src/version.ts
|
|
33
|
-
import { readFileSync as readFileSync2 } from "node:fs";
|
|
34
|
-
import { fileURLToPath } from "node:url";
|
|
35
|
-
function readVersion() {
|
|
36
|
-
try {
|
|
37
|
-
const path = fileURLToPath(new URL("../package.json", import.meta.url));
|
|
38
|
-
const pkg = JSON.parse(readFileSync2(path, "utf8"));
|
|
39
|
-
return pkg.version ?? "unknown";
|
|
40
|
-
} catch {
|
|
41
|
-
return "unknown";
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
var RUNNER_VERSION = readVersion();
|
|
45
|
-
var RUNNER_VERSION_HEADER = "x-flumecode-runner-version";
|
|
46
|
-
var RUNNER_MIN_VERSION_HEADER = "x-flumecode-min-runner-version";
|
|
47
|
-
var RUNNER_LATEST_VERSION_HEADER = "x-flumecode-latest-runner-version";
|
|
48
|
-
function compareVersions(a, b) {
|
|
49
|
-
const parse = (v) => (v.split("-")[0] ?? v).split(".").map((n) => Number.parseInt(n, 10) || 0);
|
|
50
|
-
const pa = parse(a);
|
|
51
|
-
const pb = parse(b);
|
|
52
|
-
const len = Math.max(pa.length, pb.length);
|
|
53
|
-
for (let i = 0; i < len; i++) {
|
|
54
|
-
const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
55
|
-
if (diff !== 0) return diff;
|
|
56
|
-
}
|
|
57
|
-
return 0;
|
|
58
|
-
}
|
|
679
|
+
import { existsSync as existsSync5 } from "node:fs";
|
|
680
|
+
import { join as join6 } from "node:path";
|
|
59
681
|
|
|
60
682
|
// src/api.ts
|
|
683
|
+
init_version();
|
|
61
684
|
var OUTDATED_WARN_INTERVAL_MS = 60 * 6e4;
|
|
62
685
|
var lastOutdatedWarnAt = 0;
|
|
63
686
|
var UPDATE_NUDGE_INTERVAL_MS = 60 * 6e4;
|
|
@@ -172,6 +795,20 @@ async function fetchRelatedSessions(config, params) {
|
|
|
172
795
|
return [];
|
|
173
796
|
}
|
|
174
797
|
}
|
|
798
|
+
async function mintUploadUrls(config, jobId, keys) {
|
|
799
|
+
const res = await fetch(`${config.serverUrl}/api/runner/previews/mint-upload-urls`, {
|
|
800
|
+
method: "POST",
|
|
801
|
+
headers: {
|
|
802
|
+
authorization: `Bearer ${config.token}`,
|
|
803
|
+
"content-type": "application/json",
|
|
804
|
+
[RUNNER_VERSION_HEADER]: RUNNER_VERSION
|
|
805
|
+
},
|
|
806
|
+
body: JSON.stringify({ jobId, keys })
|
|
807
|
+
});
|
|
808
|
+
noteServerVersion(res);
|
|
809
|
+
if (!res.ok) throw new Error(`mint-upload-urls failed: ${res.status} ${await safeText(res)}`);
|
|
810
|
+
return res.json();
|
|
811
|
+
}
|
|
175
812
|
async function safeText(res) {
|
|
176
813
|
try {
|
|
177
814
|
return await res.text();
|
|
@@ -1103,6 +1740,36 @@ function buildCodexPrompt(ctx) {
|
|
|
1103
1740
|
"\n"
|
|
1104
1741
|
);
|
|
1105
1742
|
}
|
|
1743
|
+
function buildPreviewPrompt(ctx, committedFileNames, tmpRoute) {
|
|
1744
|
+
const lines2 = [
|
|
1745
|
+
`You are "${ctx.agentName}" authoring an ephemeral UI showcase for ${ctx.repo.fullName}.`,
|
|
1746
|
+
`An implementation just ran and committed UI changes to branch "${ctx.repo.checkoutBranch}". Use the \`flumecode:preview-ui\` skill to create a temporary showcase page that imports the changed components and fills them with realistic fake data.`,
|
|
1747
|
+
`Write your showcase files under \`${tmpRoute}\` and write the URL path to \`${tmpRoute}/.showcase-path\`.`,
|
|
1748
|
+
`These files are ephemeral and git-excluded \u2014 do NOT commit or push them.`,
|
|
1749
|
+
"",
|
|
1750
|
+
"These coding guidelines apply to all code produced in this run:",
|
|
1751
|
+
"",
|
|
1752
|
+
loadRule("coding-guideline"),
|
|
1753
|
+
"",
|
|
1754
|
+
`# Request: ${ctx.request?.title ?? ""}`
|
|
1755
|
+
];
|
|
1756
|
+
if (ctx.request?.body) {
|
|
1757
|
+
lines2.push("", ctx.request.body);
|
|
1758
|
+
}
|
|
1759
|
+
if (committedFileNames.trim()) {
|
|
1760
|
+
lines2.push(
|
|
1761
|
+
"",
|
|
1762
|
+
"UI files changed by this implementation (showcase these components):",
|
|
1763
|
+
"",
|
|
1764
|
+
committedFileNames.trim()
|
|
1765
|
+
);
|
|
1766
|
+
}
|
|
1767
|
+
lines2.push(
|
|
1768
|
+
"",
|
|
1769
|
+
"When done, reply with one short sentence confirming what showcase file you created."
|
|
1770
|
+
);
|
|
1771
|
+
return lines2.join("\n");
|
|
1772
|
+
}
|
|
1106
1773
|
function buildInitPrompt(ctx) {
|
|
1107
1774
|
return [
|
|
1108
1775
|
`You are "${ctx.agentName}" initializing FlumeCode for the repository ${ctx.repo.fullName}, checked out in your current working directory.`,
|
|
@@ -1397,6 +2064,18 @@ async function openPullRequest(ctx) {
|
|
|
1397
2064
|
}
|
|
1398
2065
|
throw new Error(`PR creation failed: ${res.status} ${await res.text()}`);
|
|
1399
2066
|
}
|
|
2067
|
+
async function gitCommittedFiles(dir) {
|
|
2068
|
+
const { stdout: stdout2 } = await git([
|
|
2069
|
+
"-C",
|
|
2070
|
+
dir,
|
|
2071
|
+
"--no-pager",
|
|
2072
|
+
"show",
|
|
2073
|
+
"--name-only",
|
|
2074
|
+
"--format=",
|
|
2075
|
+
"HEAD"
|
|
2076
|
+
]);
|
|
2077
|
+
return stdout2;
|
|
2078
|
+
}
|
|
1400
2079
|
async function cleanup(dir) {
|
|
1401
2080
|
await rm(dir, { recursive: true, force: true });
|
|
1402
2081
|
}
|
|
@@ -1456,6 +2135,250 @@ async function prNumbersForCommit(ctx, sha) {
|
|
|
1456
2135
|
}
|
|
1457
2136
|
}
|
|
1458
2137
|
|
|
2138
|
+
// src/preview.ts
|
|
2139
|
+
import { appendFileSync, existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync5, rmSync } from "node:fs";
|
|
2140
|
+
import { createServer } from "node:net";
|
|
2141
|
+
import { join as join5, relative } from "node:path";
|
|
2142
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
2143
|
+
var PREVIEW_MAX_TURNS = 60;
|
|
2144
|
+
var CAPTURE_TIMEOUT_MS = 3e4;
|
|
2145
|
+
var DEV_SERVER_READY_TIMEOUT_MS = 6e4;
|
|
2146
|
+
var VIEWPORTS = [
|
|
2147
|
+
{ name: "mobile", width: 375, height: 812 },
|
|
2148
|
+
{ name: "tablet", width: 768, height: 1024 },
|
|
2149
|
+
{ name: "desktop", width: 1280, height: 800 }
|
|
2150
|
+
];
|
|
2151
|
+
var UI_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
2152
|
+
".tsx",
|
|
2153
|
+
".jsx",
|
|
2154
|
+
".vue",
|
|
2155
|
+
".svelte",
|
|
2156
|
+
".astro",
|
|
2157
|
+
".css",
|
|
2158
|
+
".scss",
|
|
2159
|
+
".sass",
|
|
2160
|
+
".less"
|
|
2161
|
+
]);
|
|
2162
|
+
function changedFilesTouchUi(stat) {
|
|
2163
|
+
for (const line of stat.split("\n")) {
|
|
2164
|
+
const trimmed = line.trim();
|
|
2165
|
+
if (!trimmed) continue;
|
|
2166
|
+
const dot = trimmed.lastIndexOf(".");
|
|
2167
|
+
if (dot === -1) continue;
|
|
2168
|
+
const ext = trimmed.slice(dot);
|
|
2169
|
+
if (UI_EXTENSIONS.has(ext)) return true;
|
|
2170
|
+
}
|
|
2171
|
+
return false;
|
|
2172
|
+
}
|
|
2173
|
+
function detectFramework(dir) {
|
|
2174
|
+
const pm = detectPackageManager(dir);
|
|
2175
|
+
const pkgPath = join5(dir, "package.json");
|
|
2176
|
+
if (!existsSync4(pkgPath)) return { pm, family: null, devCmd: null };
|
|
2177
|
+
let pkg;
|
|
2178
|
+
try {
|
|
2179
|
+
pkg = JSON.parse(readFileSync5(pkgPath, "utf8"));
|
|
2180
|
+
} catch {
|
|
2181
|
+
return { pm, family: null, devCmd: null };
|
|
2182
|
+
}
|
|
2183
|
+
const deps = {
|
|
2184
|
+
...pkg.dependencies,
|
|
2185
|
+
...pkg.devDependencies
|
|
2186
|
+
};
|
|
2187
|
+
const family = pickFamily(deps);
|
|
2188
|
+
const port = 0;
|
|
2189
|
+
const devCmd = family ? pickDevCmd(family, port) : null;
|
|
2190
|
+
return { pm, family, devCmd };
|
|
2191
|
+
}
|
|
2192
|
+
async function runPreviewPass(ctx, dir, committedFileNames, config, abort) {
|
|
2193
|
+
const tmpRoute = mkTempRouteDir(dir);
|
|
2194
|
+
try {
|
|
2195
|
+
gitExclude(dir, tmpRoute);
|
|
2196
|
+
await runClaudeCode({
|
|
2197
|
+
cwd: dir,
|
|
2198
|
+
prompt: buildPreviewPrompt(ctx, committedFileNames, tmpRoute),
|
|
2199
|
+
permissionMode: ctx.permissionMode,
|
|
2200
|
+
abortController: abort,
|
|
2201
|
+
maxTurns: PREVIEW_MAX_TURNS
|
|
2202
|
+
});
|
|
2203
|
+
const showcasePath = readShowcasePath(tmpRoute);
|
|
2204
|
+
if (!showcasePath) {
|
|
2205
|
+
throw new Error("preview-ui skill did not write .showcase-path \u2014 skipping preview");
|
|
2206
|
+
}
|
|
2207
|
+
const framework = detectFramework(dir);
|
|
2208
|
+
if (!framework.family) {
|
|
2209
|
+
throw new Error("unsupported framework \u2014 skipping preview");
|
|
2210
|
+
}
|
|
2211
|
+
const port = await findFreePort();
|
|
2212
|
+
const devServer = await startDevServer(dir, framework, port);
|
|
2213
|
+
try {
|
|
2214
|
+
const url = `http://localhost:${port}${showcasePath}`;
|
|
2215
|
+
const shots = await captureWithChromium(url, VIEWPORTS);
|
|
2216
|
+
const { previewId, urls } = await mintUploadUrls(config, ctx.jobId, shots.keys);
|
|
2217
|
+
await uploadAll(urls, shots.keys, shots.bytes);
|
|
2218
|
+
return { previewId, entrypoint: shots.entrypointKey };
|
|
2219
|
+
} finally {
|
|
2220
|
+
await devServer.stop();
|
|
2221
|
+
}
|
|
2222
|
+
} finally {
|
|
2223
|
+
rmSync(tmpRoute, { recursive: true, force: true });
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
function pickFamily(deps) {
|
|
2227
|
+
if (deps["next"]) return "next";
|
|
2228
|
+
if (deps["@sveltejs/kit"]) return "svelte-kit";
|
|
2229
|
+
if (deps["nuxt"]) return "nuxt";
|
|
2230
|
+
if (deps["astro"]) return "astro";
|
|
2231
|
+
if (deps["react-scripts"]) return "cra";
|
|
2232
|
+
if (deps["@remix-run/react"] || deps["@remix-run/node"]) return "remix";
|
|
2233
|
+
if (deps["vite"]) {
|
|
2234
|
+
if (deps["vue"]) return "vite-vue";
|
|
2235
|
+
if (deps["svelte"]) return "vite-svelte";
|
|
2236
|
+
if (deps["react"]) return "vite-react";
|
|
2237
|
+
return "vite-react";
|
|
2238
|
+
}
|
|
2239
|
+
return null;
|
|
2240
|
+
}
|
|
2241
|
+
function pickDevCmd(family, port) {
|
|
2242
|
+
switch (family) {
|
|
2243
|
+
case "next":
|
|
2244
|
+
return ["next", "dev", "-p", String(port)];
|
|
2245
|
+
case "vite-react":
|
|
2246
|
+
case "vite-vue":
|
|
2247
|
+
case "vite-svelte":
|
|
2248
|
+
return ["vite", "--port", String(port)];
|
|
2249
|
+
case "nuxt":
|
|
2250
|
+
return ["nuxt", "dev", "--port", String(port)];
|
|
2251
|
+
case "svelte-kit":
|
|
2252
|
+
return ["vite", "--port", String(port)];
|
|
2253
|
+
case "astro":
|
|
2254
|
+
return ["astro", "dev", "--port", String(port)];
|
|
2255
|
+
case "cra":
|
|
2256
|
+
return ["react-scripts", "start"];
|
|
2257
|
+
case "remix":
|
|
2258
|
+
return ["remix", "dev", "--port", String(port)];
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
function mkTempRouteDir(dir) {
|
|
2262
|
+
const suffix = Math.random().toString(36).slice(2, 10);
|
|
2263
|
+
const tmpRoute = join5(dir, `__flumecode_preview_${suffix}`);
|
|
2264
|
+
mkdirSync2(tmpRoute, { recursive: true });
|
|
2265
|
+
return tmpRoute;
|
|
2266
|
+
}
|
|
2267
|
+
function gitExclude(dir, tmpRoute) {
|
|
2268
|
+
const excludePath = join5(dir, ".git", "info", "exclude");
|
|
2269
|
+
const rel = relative(dir, tmpRoute);
|
|
2270
|
+
appendFileSync(excludePath, `
|
|
2271
|
+
${rel}
|
|
2272
|
+
`);
|
|
2273
|
+
}
|
|
2274
|
+
function readShowcasePath(tmpRoute) {
|
|
2275
|
+
const sentinelPath = join5(tmpRoute, ".showcase-path");
|
|
2276
|
+
if (!existsSync4(sentinelPath)) return null;
|
|
2277
|
+
const content = readFileSync5(sentinelPath, "utf8").trim();
|
|
2278
|
+
return content.length > 0 ? content : null;
|
|
2279
|
+
}
|
|
2280
|
+
function findFreePort() {
|
|
2281
|
+
return new Promise((resolve, reject) => {
|
|
2282
|
+
const server = createServer();
|
|
2283
|
+
server.listen(0, "127.0.0.1", () => {
|
|
2284
|
+
const addr = server.address();
|
|
2285
|
+
if (!addr || typeof addr === "string") {
|
|
2286
|
+
server.close();
|
|
2287
|
+
reject(new Error("Could not determine free port"));
|
|
2288
|
+
return;
|
|
2289
|
+
}
|
|
2290
|
+
const port = addr.port;
|
|
2291
|
+
server.close((err) => {
|
|
2292
|
+
if (err) reject(err);
|
|
2293
|
+
else resolve(port);
|
|
2294
|
+
});
|
|
2295
|
+
});
|
|
2296
|
+
server.on("error", reject);
|
|
2297
|
+
});
|
|
2298
|
+
}
|
|
2299
|
+
function startDevServer(dir, framework, port) {
|
|
2300
|
+
return new Promise((resolve, reject) => {
|
|
2301
|
+
const pm = framework.pm ?? "npm";
|
|
2302
|
+
const devCmd = pickDevCmd(framework.family, port);
|
|
2303
|
+
const argv = [pm === "npm" ? "npx" : "exec", ...devCmd];
|
|
2304
|
+
const cmd = pm === "npm" ? "npx" : pm;
|
|
2305
|
+
const args = pm === "npm" ? devCmd : ["exec", ...devCmd];
|
|
2306
|
+
const proc = spawn2(cmd, args, {
|
|
2307
|
+
cwd: dir,
|
|
2308
|
+
env: { ...process.env, PORT: String(port) },
|
|
2309
|
+
stdio: "ignore"
|
|
2310
|
+
});
|
|
2311
|
+
proc.on("error", reject);
|
|
2312
|
+
const baseUrl = `http://localhost:${port}`;
|
|
2313
|
+
const deadline = Date.now() + DEV_SERVER_READY_TIMEOUT_MS;
|
|
2314
|
+
const poll = () => {
|
|
2315
|
+
fetch(baseUrl).then((res) => {
|
|
2316
|
+
if (res.ok || res.status < 500) {
|
|
2317
|
+
resolve({
|
|
2318
|
+
stop: () => new Promise((res2) => {
|
|
2319
|
+
proc.kill();
|
|
2320
|
+
proc.on("close", () => res2());
|
|
2321
|
+
setTimeout(res2, 500);
|
|
2322
|
+
})
|
|
2323
|
+
});
|
|
2324
|
+
} else {
|
|
2325
|
+
scheduleNext();
|
|
2326
|
+
}
|
|
2327
|
+
}).catch(() => scheduleNext());
|
|
2328
|
+
};
|
|
2329
|
+
const scheduleNext = () => {
|
|
2330
|
+
if (Date.now() > deadline) {
|
|
2331
|
+
proc.kill();
|
|
2332
|
+
reject(
|
|
2333
|
+
new Error(`Dev server did not become ready within ${DEV_SERVER_READY_TIMEOUT_MS}ms`)
|
|
2334
|
+
);
|
|
2335
|
+
return;
|
|
2336
|
+
}
|
|
2337
|
+
setTimeout(poll, 1e3);
|
|
2338
|
+
};
|
|
2339
|
+
setTimeout(poll, 2e3);
|
|
2340
|
+
});
|
|
2341
|
+
}
|
|
2342
|
+
async function captureWithChromium(url, viewports) {
|
|
2343
|
+
const { chromium } = await import("playwright");
|
|
2344
|
+
const browser = await chromium.launch({ headless: true });
|
|
2345
|
+
const keys = [];
|
|
2346
|
+
const bytes = [];
|
|
2347
|
+
try {
|
|
2348
|
+
for (const vp of viewports) {
|
|
2349
|
+
const context = await browser.newContext({
|
|
2350
|
+
viewport: { width: vp.width, height: vp.height }
|
|
2351
|
+
});
|
|
2352
|
+
const page = await context.newPage();
|
|
2353
|
+
await page.goto(url, { timeout: CAPTURE_TIMEOUT_MS, waitUntil: "networkidle" });
|
|
2354
|
+
const screenshot = await page.screenshot({ fullPage: true, type: "png" });
|
|
2355
|
+
keys.push(`${vp.name}.png`);
|
|
2356
|
+
bytes.push(Buffer.from(screenshot));
|
|
2357
|
+
await context.close();
|
|
2358
|
+
}
|
|
2359
|
+
} finally {
|
|
2360
|
+
await browser.close();
|
|
2361
|
+
}
|
|
2362
|
+
return {
|
|
2363
|
+
keys,
|
|
2364
|
+
bytes,
|
|
2365
|
+
entrypointKey: keys[keys.length - 1] ?? "desktop.png"
|
|
2366
|
+
};
|
|
2367
|
+
}
|
|
2368
|
+
async function uploadAll(urls, keys, bytes) {
|
|
2369
|
+
await Promise.all(
|
|
2370
|
+
keys.map((key, i) => {
|
|
2371
|
+
const url = urls[key];
|
|
2372
|
+
if (!url) return Promise.resolve();
|
|
2373
|
+
return fetch(url, {
|
|
2374
|
+
method: "PUT",
|
|
2375
|
+
headers: { "content-type": "image/png" },
|
|
2376
|
+
body: bytes[i]
|
|
2377
|
+
});
|
|
2378
|
+
})
|
|
2379
|
+
);
|
|
2380
|
+
}
|
|
2381
|
+
|
|
1459
2382
|
// src/run.ts
|
|
1460
2383
|
var IDLE_MS = 5e3;
|
|
1461
2384
|
var CANCEL_POLL_MS = 2500;
|
|
@@ -1650,7 +2573,7 @@ async function processChatJob(ctx, dir, config, abort) {
|
|
|
1650
2573
|
console.log(` \u2026job ${ctx.jobId} posted ${result.widgets.length} widget(s); awaiting reply`);
|
|
1651
2574
|
return { text: reply, widgets: result.widgets };
|
|
1652
2575
|
}
|
|
1653
|
-
const wikiExists =
|
|
2576
|
+
const wikiExists = existsSync5(join6(dir, ".flumecode", "wiki"));
|
|
1654
2577
|
let documented = false;
|
|
1655
2578
|
if (ctx.permissionMode !== "plan" && wikiExists && await hasChanges(dir)) {
|
|
1656
2579
|
try {
|
|
@@ -1739,7 +2662,7 @@ ${reply}`;
|
|
|
1739
2662
|
|
|
1740
2663
|
> \u26A0\uFE0F Dependencies failed to install (\`${installResult.manager}\`); tests may not have run.`;
|
|
1741
2664
|
}
|
|
1742
|
-
const wikiExists =
|
|
2665
|
+
const wikiExists = existsSync5(join6(dir, ".flumecode", "wiki"));
|
|
1743
2666
|
let documented = false;
|
|
1744
2667
|
if (wikiExists && await hasChanges(dir)) {
|
|
1745
2668
|
try {
|
|
@@ -1764,11 +2687,24 @@ ${reply}`;
|
|
|
1764
2687
|
});
|
|
1765
2688
|
reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, documented, autoMerged });
|
|
1766
2689
|
const finalReport = report && conflictResolution ? { ...report, conflictResolution } : report;
|
|
2690
|
+
let preview;
|
|
2691
|
+
if (outcome.kind !== "none") {
|
|
2692
|
+
const committedFileNames = await gitCommittedFiles(dir);
|
|
2693
|
+
if (changedFilesTouchUi(committedFileNames)) {
|
|
2694
|
+
try {
|
|
2695
|
+
console.log(` \u2026generating UI preview for implement ${ctx.jobId}`);
|
|
2696
|
+
preview = await runPreviewPass(ctx, dir, committedFileNames, config, abort);
|
|
2697
|
+
} catch (err) {
|
|
2698
|
+
console.warn(` preview skipped: ${errorMessage2(err)}`);
|
|
2699
|
+
}
|
|
2700
|
+
}
|
|
2701
|
+
}
|
|
1767
2702
|
return {
|
|
1768
2703
|
text: reply,
|
|
1769
2704
|
widgets: [],
|
|
1770
2705
|
...finalReport ? { report: finalReport } : {},
|
|
1771
|
-
...outcome.kind === "pr" ? { pr: outcome.pr } : {}
|
|
2706
|
+
...outcome.kind === "pr" ? { pr: outcome.pr } : {},
|
|
2707
|
+
...preview ? { preview } : {}
|
|
1772
2708
|
};
|
|
1773
2709
|
}
|
|
1774
2710
|
async function processReviseJob(ctx, dir, resumed, config, abort) {
|
|
@@ -1795,7 +2731,7 @@ async function processReviseJob(ctx, dir, resumed, config, abort) {
|
|
|
1795
2731
|
console.log(` \u2026revise ${ctx.jobId} posted ${result.widgets.length} widget(s); awaiting reply`);
|
|
1796
2732
|
return { text: reply, widgets: result.widgets };
|
|
1797
2733
|
}
|
|
1798
|
-
const wikiExists =
|
|
2734
|
+
const wikiExists = existsSync5(join6(dir, ".flumecode", "wiki"));
|
|
1799
2735
|
let documented = false;
|
|
1800
2736
|
if (wikiExists && await hasChanges(dir)) {
|
|
1801
2737
|
try {
|
|
@@ -1822,12 +2758,25 @@ async function processReviseJob(ctx, dir, resumed, config, abort) {
|
|
|
1822
2758
|
reply += outcomeBanner(outcome, { branch: ctx.repo.checkoutBranch, documented, autoMerged });
|
|
1823
2759
|
}
|
|
1824
2760
|
const finalReport = report && conflictResolution ? { ...report, conflictResolution } : report;
|
|
2761
|
+
let preview;
|
|
2762
|
+
if (outcome.kind !== "none") {
|
|
2763
|
+
const committedFileNames = await gitCommittedFiles(dir);
|
|
2764
|
+
if (changedFilesTouchUi(committedFileNames)) {
|
|
2765
|
+
try {
|
|
2766
|
+
console.log(` \u2026generating UI preview for revise ${ctx.jobId}`);
|
|
2767
|
+
preview = await runPreviewPass(ctx, dir, committedFileNames, config, abort);
|
|
2768
|
+
} catch (err) {
|
|
2769
|
+
console.warn(` preview skipped: ${errorMessage2(err)}`);
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
}
|
|
1825
2773
|
return {
|
|
1826
2774
|
text: reply,
|
|
1827
2775
|
widgets: [],
|
|
1828
2776
|
...finalReport ? { report: finalReport } : {},
|
|
1829
2777
|
...outcome.kind === "pr" ? { pr: outcome.pr } : {},
|
|
1830
|
-
...result.plans?.length ? { plans: result.plans } : {}
|
|
2778
|
+
...result.plans?.length ? { plans: result.plans } : {},
|
|
2779
|
+
...preview ? { preview } : {}
|
|
1831
2780
|
};
|
|
1832
2781
|
}
|
|
1833
2782
|
async function processResolveJob(ctx, dir, config, abort) {
|
|
@@ -1939,7 +2888,7 @@ async function pollLoop(config) {
|
|
|
1939
2888
|
scheduleCancelPoll();
|
|
1940
2889
|
try {
|
|
1941
2890
|
resetUsage();
|
|
1942
|
-
const { text, widgets, pr, plans, report } = await processJob(ctx, config, abort);
|
|
2891
|
+
const { text, widgets, pr, plans, report, preview } = await processJob(ctx, config, abort);
|
|
1943
2892
|
const usage = getUsage();
|
|
1944
2893
|
await reportJob(config, ctx.jobId, {
|
|
1945
2894
|
status: "done",
|
|
@@ -1948,6 +2897,7 @@ async function pollLoop(config) {
|
|
|
1948
2897
|
pr,
|
|
1949
2898
|
...plans?.length ? { plans } : {},
|
|
1950
2899
|
...report ? { report } : {},
|
|
2900
|
+
...preview ? { preview } : {},
|
|
1951
2901
|
...usage.totalTokens > 0 ? { usage } : {}
|
|
1952
2902
|
});
|
|
1953
2903
|
console.log(`\u2713 Job ${ctx.jobId} done`);
|
|
@@ -2027,6 +2977,8 @@ function formatJobError(ctx, err) {
|
|
|
2027
2977
|
}
|
|
2028
2978
|
|
|
2029
2979
|
// src/cli.ts
|
|
2980
|
+
init_version();
|
|
2981
|
+
import React7 from "react";
|
|
2030
2982
|
var DEFAULT_SERVER = process.env.FLUME_SERVER || "http://localhost:3000";
|
|
2031
2983
|
async function login(args) {
|
|
2032
2984
|
const flags = parseFlags(args);
|
|
@@ -2061,6 +3013,21 @@ async function start() {
|
|
|
2061
3013
|
}
|
|
2062
3014
|
await pollLoop(config);
|
|
2063
3015
|
}
|
|
3016
|
+
async function runTui(args) {
|
|
3017
|
+
const flags = parseFlags(args);
|
|
3018
|
+
let config = readConfig() ?? { serverUrl: flags.server ?? DEFAULT_SERVER, token: "" };
|
|
3019
|
+
if (flags.server) config.serverUrl = flags.server.replace(/\/+$/, "");
|
|
3020
|
+
if (flags.token) config.apiToken = flags.token;
|
|
3021
|
+
if (!config.apiToken) {
|
|
3022
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
3023
|
+
config.apiToken = (await rl.question("CLI token (flcli_\u2026): ")).trim();
|
|
3024
|
+
rl.close();
|
|
3025
|
+
}
|
|
3026
|
+
writeConfig(config);
|
|
3027
|
+
const { render } = await import("ink");
|
|
3028
|
+
const { App: App2 } = await Promise.resolve().then(() => (init_App(), App_exports));
|
|
3029
|
+
render(React7.createElement(App2, { config }));
|
|
3030
|
+
}
|
|
2064
3031
|
function parseFlags(args) {
|
|
2065
3032
|
const out = {};
|
|
2066
3033
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -2078,11 +3045,14 @@ if (command === "--version" || command === "-v" || command === "version") {
|
|
|
2078
3045
|
void login(rest);
|
|
2079
3046
|
} else if (command === "start") {
|
|
2080
3047
|
void start();
|
|
3048
|
+
} else if (command === "tui") {
|
|
3049
|
+
void runTui(rest);
|
|
2081
3050
|
} else {
|
|
2082
3051
|
console.log(`FlumeCode runner v${RUNNER_VERSION}`);
|
|
2083
3052
|
console.log("Usage:");
|
|
2084
3053
|
console.log(" flumecode login # save server URL + token");
|
|
2085
3054
|
console.log(" flumecode start # poll for and run jobs");
|
|
3055
|
+
console.log(" flumecode tui # open the interactive TUI");
|
|
2086
3056
|
console.log(" flumecode --version # print the runner version");
|
|
2087
3057
|
process.exit(command ? 1 : 0);
|
|
2088
3058
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flumecode/runner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "FlumeCode local runner — claims jobs and drives your local Claude Code against a real checkout.",
|
|
6
6
|
"bin": {
|
|
@@ -28,11 +28,17 @@
|
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"@anthropic-ai/claude-agent-sdk": "^0.3.0",
|
|
30
30
|
"@modelcontextprotocol/sdk": "^1",
|
|
31
|
+
"ink": "^5.2.1",
|
|
32
|
+
"ink-text-input": "^6.0.0",
|
|
33
|
+
"playwright": "^1.52.0",
|
|
34
|
+
"react": "^18.3.1",
|
|
31
35
|
"zod": "4.4.3"
|
|
32
36
|
},
|
|
33
37
|
"devDependencies": {
|
|
34
38
|
"@types/node": "^22.10.5",
|
|
39
|
+
"@types/react": "^19.0.0",
|
|
35
40
|
"esbuild": "^0.24.2",
|
|
41
|
+
"ink-testing-library": "^4.0.0",
|
|
36
42
|
"tsx": "^4.19.2",
|
|
37
43
|
"typescript": "^5.7.3"
|
|
38
44
|
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: preview-ui
|
|
3
|
+
description: >-
|
|
4
|
+
Author an ephemeral fake-data showcase page for changed UI components so the
|
|
5
|
+
runner can screenshot them in a headless browser. Use after a UI-touching
|
|
6
|
+
implementation, when the runner provides a tmpRoute directory. Writes the
|
|
7
|
+
showcase files there and records the URL path in a sentinel file. Never
|
|
8
|
+
commits, pushes, or modifies application code outside the tmpRoute directory.
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# preview-ui
|
|
12
|
+
|
|
13
|
+
You author a **temporary showcase page** that imports the recently-changed UI
|
|
14
|
+
components and fills them with realistic fake data, so the runner can start the
|
|
15
|
+
repo's dev server and take headless screenshots.
|
|
16
|
+
|
|
17
|
+
You write only inside the `<tmpRoute>` directory the runner hands you. You never
|
|
18
|
+
modify production code, commit, or push.
|
|
19
|
+
|
|
20
|
+
## What you receive
|
|
21
|
+
|
|
22
|
+
The runner injects these into your prompt:
|
|
23
|
+
|
|
24
|
+
- **`<tmpRoute>`** — an absolute path to an empty temp directory inside the repo
|
|
25
|
+
(already git-excluded). Write your showcase files here.
|
|
26
|
+
- **Committed UI files** — the list of files changed by the implementation, so
|
|
27
|
+
you know which components to showcase.
|
|
28
|
+
|
|
29
|
+
## Step 1 — Detect the framework
|
|
30
|
+
|
|
31
|
+
Read the repo's `package.json` (and `package.json` files in workspaces, if any)
|
|
32
|
+
to determine the framework:
|
|
33
|
+
|
|
34
|
+
| Framework | Key `dependencies` / `devDependencies` |
|
|
35
|
+
| ------------- | --------------------------------------- |
|
|
36
|
+
| Next.js | `next` |
|
|
37
|
+
| Vite + React | `vite` + `react` |
|
|
38
|
+
| Vite + Vue | `vite` + `vue` |
|
|
39
|
+
| Vite + Svelte | `vite` + `svelte` (no SvelteKit) |
|
|
40
|
+
| SvelteKit | `@sveltejs/kit` |
|
|
41
|
+
| Nuxt | `nuxt` |
|
|
42
|
+
| Astro | `astro` |
|
|
43
|
+
| CRA | `react-scripts` |
|
|
44
|
+
| Remix | `@remix-run/react` or `@remix-run/node` |
|
|
45
|
+
|
|
46
|
+
If the framework is not recognisable or the file list contains no importable
|
|
47
|
+
components, write a single plain `<tmpRoute>/index.html` file with a message
|
|
48
|
+
like "No supported framework detected" and write `/__flumecode_preview` to
|
|
49
|
+
`<tmpRoute>/.showcase-path`. Then stop — do not create a route file.
|
|
50
|
+
|
|
51
|
+
## Step 2 — Determine the entry path
|
|
52
|
+
|
|
53
|
+
Choose a URL path unlikely to collide with real routes: `/__flumecode_preview`.
|
|
54
|
+
Write that string (exactly, no trailing newline) to `<tmpRoute>/.showcase-path`.
|
|
55
|
+
|
|
56
|
+
## Step 3 — Author the showcase entry file
|
|
57
|
+
|
|
58
|
+
Create a single route/page file at the correct location under `<tmpRoute>` for
|
|
59
|
+
the detected framework:
|
|
60
|
+
|
|
61
|
+
| Framework | File to create |
|
|
62
|
+
| ---------------------- | ----------------------------------------------------- |
|
|
63
|
+
| Next.js (App Router) | `<tmpRoute>/page.tsx` (if the project uses `app/`) |
|
|
64
|
+
| Next.js (Pages Router) | `<tmpRoute>/index.tsx` (if the project uses `pages/`) |
|
|
65
|
+
| Vite + React | `<tmpRoute>/App.tsx` (or `.jsx`) |
|
|
66
|
+
| Vite + Vue | `<tmpRoute>/App.vue` |
|
|
67
|
+
| Vite + Svelte | `<tmpRoute>/App.svelte` |
|
|
68
|
+
| SvelteKit | `<tmpRoute>/+page.svelte` |
|
|
69
|
+
| Nuxt | `<tmpRoute>/index.vue` |
|
|
70
|
+
| Astro | `<tmpRoute>/index.astro` |
|
|
71
|
+
| CRA | `<tmpRoute>/index.tsx` |
|
|
72
|
+
| Remix | `<tmpRoute>/route.tsx` |
|
|
73
|
+
|
|
74
|
+
**Next.js App Router note:** The runner mounts the showcase at
|
|
75
|
+
`app/__flumecode_preview/page.tsx` by symlinking or copying `<tmpRoute>` to
|
|
76
|
+
`app/__flumecode_preview/`. You only need to produce `<tmpRoute>/page.tsx` — the
|
|
77
|
+
runner handles the mount.
|
|
78
|
+
|
|
79
|
+
### Content rules
|
|
80
|
+
|
|
81
|
+
- Import the changed components using their real relative paths (calculate the
|
|
82
|
+
path from `<tmpRoute>` to the component's source location).
|
|
83
|
+
- Fill every prop with **realistic fake data** — use hardcoded literals, not
|
|
84
|
+
calls to external APIs or databases.
|
|
85
|
+
- If a component requires a provider (React context, Vuex store, Pinia, etc.),
|
|
86
|
+
wrap it with a minimal in-file stub provider — do not import the real app
|
|
87
|
+
store or data layer.
|
|
88
|
+
- If a component calls a route handler or fetch endpoint, stub the relevant
|
|
89
|
+
function or hook at the top of the file with a mock that returns realistic
|
|
90
|
+
hard-coded data. Do NOT import MSW or any test library; keep stubs as plain
|
|
91
|
+
module-level overrides.
|
|
92
|
+
- Export the showcase page as the default export (except Astro/SvelteKit, which
|
|
93
|
+
don't require a default export).
|
|
94
|
+
- Keep the file short: one `export default` function that renders all changed
|
|
95
|
+
components in a flex column, with a small amount of padding.
|
|
96
|
+
|
|
97
|
+
### What NOT to do
|
|
98
|
+
|
|
99
|
+
- Do not call `fetch`, `axios`, `prisma`, `supabase`, or any I/O in the
|
|
100
|
+
showcase.
|
|
101
|
+
- Do not import from test utilities, MSW, Storybook, or Cypress.
|
|
102
|
+
- Do not add new npm dependencies.
|
|
103
|
+
- Do not edit any file outside `<tmpRoute>`.
|
|
104
|
+
- Do not commit or push.
|
|
105
|
+
|
|
106
|
+
## Step 4 — Verify your output
|
|
107
|
+
|
|
108
|
+
Before finishing, confirm:
|
|
109
|
+
|
|
110
|
+
1. `<tmpRoute>/.showcase-path` exists and contains the URL path string.
|
|
111
|
+
2. The showcase entry file exists at the expected path under `<tmpRoute>`.
|
|
112
|
+
3. The file imports only modules that already exist in the repo (no invented
|
|
113
|
+
paths).
|
|
114
|
+
|
|
115
|
+
If you cannot produce a valid showcase (e.g. the components have complex
|
|
116
|
+
dependencies you cannot stub), write only `<tmpRoute>/.showcase-path` with the
|
|
117
|
+
URL string and leave the entry file absent — the runner will detect the missing
|
|
118
|
+
file and skip the screenshot step gracefully.
|
|
119
|
+
|
|
120
|
+
## Always
|
|
121
|
+
|
|
122
|
+
- Write the URL path to `<tmpRoute>/.showcase-path` first, before any other
|
|
123
|
+
file — the runner reads it even if something else fails.
|
|
124
|
+
- These files are ephemeral and git-excluded. They will be deleted by the runner
|
|
125
|
+
after screenshots are taken. Never commit or push them.
|
|
126
|
+
- Your final reply should be one short sentence confirming what you created (e.g.
|
|
127
|
+
"Wrote `<tmpRoute>/page.tsx` showcasing `Button` and `Card` with fake data.").
|