stack-agent 0.1.0 → 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 +5 -0
- package/dist/index.js +1506 -263
- package/package.json +11 -3
package/dist/index.js
CHANGED
|
@@ -1,46 +1,484 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import
|
|
4
|
+
import React6 from "react";
|
|
5
|
+
import { withFullScreen } from "fullscreen-ink";
|
|
5
6
|
|
|
6
|
-
// src/cli/
|
|
7
|
-
import
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
// src/cli/app.tsx
|
|
8
|
+
import { useState as useState5, useEffect as useEffect2, useCallback } from "react";
|
|
9
|
+
import { Box as Box7, Text as Text7, useApp, useInput as useInput3 } from "ink";
|
|
10
|
+
import { useScreenSize } from "fullscreen-ink";
|
|
11
|
+
|
|
12
|
+
// src/cli/components/header.tsx
|
|
13
|
+
import { Box, Text } from "ink";
|
|
14
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
15
|
+
function Header({ appName, currentStage, stages, showDots = false }) {
|
|
16
|
+
const stageName = currentStage?.label ?? "Stack Progress";
|
|
17
|
+
return /* @__PURE__ */ jsxs(Box, { borderStyle: "single", borderBottom: false, paddingX: 1, justifyContent: "space-between", children: [
|
|
18
|
+
/* @__PURE__ */ jsxs(Box, { gap: 2, children: [
|
|
19
|
+
/* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: appName }),
|
|
20
|
+
/* @__PURE__ */ jsx(Text, { bold: true, children: stageName })
|
|
21
|
+
] }),
|
|
22
|
+
showDots && /* @__PURE__ */ jsx(Box, { gap: 0, children: stages.map((s, i) => /* @__PURE__ */ jsx(StageDot, { stage: s, isCurrent: s.id === currentStage?.id }, s.id)) })
|
|
23
|
+
] });
|
|
13
24
|
}
|
|
14
|
-
function
|
|
15
|
-
|
|
25
|
+
function StageDot({ stage, isCurrent }) {
|
|
26
|
+
if (isCurrent) {
|
|
27
|
+
return /* @__PURE__ */ jsx(Text, { color: "cyan", children: "\u25CF" });
|
|
28
|
+
}
|
|
29
|
+
if (stage.status === "complete" && stage.confirmed) {
|
|
30
|
+
return /* @__PURE__ */ jsx(Text, { color: "green", children: "\u25CF" });
|
|
31
|
+
}
|
|
32
|
+
if (stage.status === "complete" && !stage.confirmed) {
|
|
33
|
+
return /* @__PURE__ */ jsx(Text, { color: "yellow", children: "\u25CF" });
|
|
34
|
+
}
|
|
35
|
+
if (stage.status === "skipped") {
|
|
36
|
+
return /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u2013" });
|
|
37
|
+
}
|
|
38
|
+
return /* @__PURE__ */ jsx(Text, { dimColor: true, children: "\u25CB" });
|
|
16
39
|
}
|
|
17
|
-
|
|
18
|
-
|
|
40
|
+
|
|
41
|
+
// src/cli/components/footer.tsx
|
|
42
|
+
import { Box as Box2, Text as Text2 } from "ink";
|
|
43
|
+
import { jsx as jsx2 } from "react/jsx-runtime";
|
|
44
|
+
function Footer({ progress, stages, terminalWidth, mode = "decisions" }) {
|
|
45
|
+
let display;
|
|
46
|
+
switch (mode) {
|
|
47
|
+
case "stage_list":
|
|
48
|
+
display = "\u2191\u2193 navigate \xB7 Enter select \xB7 Esc back";
|
|
49
|
+
break;
|
|
50
|
+
case "options":
|
|
51
|
+
display = "\u2191\u2193 navigate \xB7 Enter select \xB7 Esc stages";
|
|
52
|
+
break;
|
|
53
|
+
case "input":
|
|
54
|
+
display = "Enter submit \xB7 Esc stages";
|
|
55
|
+
break;
|
|
56
|
+
default:
|
|
57
|
+
display = buildDecisionsDisplay(progress, stages, terminalWidth);
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
return /* @__PURE__ */ jsx2(Box2, { borderStyle: "single", borderTop: false, paddingX: 1, children: /* @__PURE__ */ jsx2(Text2, { dimColor: true, children: display }) });
|
|
19
61
|
}
|
|
20
|
-
function
|
|
21
|
-
|
|
62
|
+
function buildDecisionsDisplay(progress, stages, terminalWidth) {
|
|
63
|
+
const decisions = [];
|
|
64
|
+
if (progress.projectName) decisions.push(`Project: ${progress.projectName}`);
|
|
65
|
+
if (progress.frontend) decisions.push(`Frontend: ${progress.frontend.component}`);
|
|
66
|
+
if (progress.backend) decisions.push(`Backend: ${progress.backend.component}`);
|
|
67
|
+
if (progress.database) decisions.push(`DB: ${progress.database.component}`);
|
|
68
|
+
if (progress.auth) decisions.push(`Auth: ${progress.auth.component}`);
|
|
69
|
+
if (progress.payments) decisions.push(`Pay: ${progress.payments.component}`);
|
|
70
|
+
if (progress.ai) decisions.push(`AI: ${progress.ai.component}`);
|
|
71
|
+
if (progress.deployment) decisions.push(`Deploy: ${progress.deployment.component}`);
|
|
72
|
+
const nextStage = stages.find((s) => s.status === "pending");
|
|
73
|
+
const nextText = nextStage ? `Next: ${nextStage.label}` : "";
|
|
74
|
+
const separator = " \u2502 ";
|
|
75
|
+
let display = decisions.map((d) => `\u2713 ${d}`).join(separator);
|
|
76
|
+
if (nextText) {
|
|
77
|
+
display = display ? `${display}${separator}${nextText}` : nextText;
|
|
78
|
+
}
|
|
79
|
+
const maxWidth = terminalWidth - 4;
|
|
80
|
+
if (display.length > maxWidth) {
|
|
81
|
+
display = display.slice(0, maxWidth - 1) + "\u2026";
|
|
82
|
+
}
|
|
83
|
+
return display;
|
|
22
84
|
}
|
|
23
|
-
|
|
24
|
-
|
|
85
|
+
|
|
86
|
+
// src/cli/components/conversation.tsx
|
|
87
|
+
import { useState, useEffect } from "react";
|
|
88
|
+
import { Box as Box3, Text as Text3 } from "ink";
|
|
89
|
+
import { Spinner } from "@inkjs/ui";
|
|
90
|
+
import { jsx as jsx3 } from "react/jsx-runtime";
|
|
91
|
+
function ConversationView({ bridge, maxLines }) {
|
|
92
|
+
const [text, setText] = useState("");
|
|
93
|
+
const [isStreaming, setIsStreaming] = useState(false);
|
|
94
|
+
const [showSpinner, setShowSpinner] = useState(false);
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
const unsubs = [
|
|
97
|
+
bridge.subscribe("spinnerStart", () => {
|
|
98
|
+
setShowSpinner(true);
|
|
99
|
+
setIsStreaming(false);
|
|
100
|
+
setText("");
|
|
101
|
+
}),
|
|
102
|
+
bridge.subscribe("streamText", (delta) => {
|
|
103
|
+
setShowSpinner(false);
|
|
104
|
+
setIsStreaming(true);
|
|
105
|
+
setText((prev) => prev + delta);
|
|
106
|
+
}),
|
|
107
|
+
bridge.subscribe("streamEnd", () => {
|
|
108
|
+
setIsStreaming(false);
|
|
109
|
+
})
|
|
110
|
+
];
|
|
111
|
+
return () => unsubs.forEach((fn) => fn());
|
|
112
|
+
}, [bridge]);
|
|
113
|
+
if (showSpinner) {
|
|
114
|
+
return /* @__PURE__ */ jsx3(Box3, { paddingX: 1, children: /* @__PURE__ */ jsx3(Spinner, { label: "Thinking..." }) });
|
|
115
|
+
}
|
|
116
|
+
const lines = text.split("\n");
|
|
117
|
+
const visible = lines.slice(-maxLines);
|
|
118
|
+
return /* @__PURE__ */ jsx3(Box3, { flexDirection: "column", paddingX: 1, children: visible.map((line, i) => /* @__PURE__ */ jsx3(Text3, { children: line }, i)) });
|
|
25
119
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
120
|
+
|
|
121
|
+
// src/cli/components/option-select.tsx
|
|
122
|
+
import { useState as useState2 } from "react";
|
|
123
|
+
import { Box as Box4, Text as Text4, useInput } from "ink";
|
|
124
|
+
import { jsx as jsx4, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
125
|
+
function OptionSelect({ options, onSelect }) {
|
|
126
|
+
const [cursor, setCursor] = useState2(0);
|
|
127
|
+
const [textValue, setTextValue] = useState2("");
|
|
128
|
+
const totalItems = options.length + 1;
|
|
129
|
+
const isOnTextField = cursor === options.length;
|
|
130
|
+
useInput((input, key) => {
|
|
131
|
+
if (key.upArrow) {
|
|
132
|
+
setCursor((c) => Math.max(0, c - 1));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (key.downArrow) {
|
|
136
|
+
setCursor((c) => Math.min(totalItems - 1, c + 1));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (key.return) {
|
|
140
|
+
if (isOnTextField) {
|
|
141
|
+
if (textValue.trim()) {
|
|
142
|
+
onSelect({ kind: "text", value: textValue.trim() });
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
onSelect({ kind: "select", value: options[cursor].label });
|
|
146
|
+
}
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (isOnTextField) {
|
|
150
|
+
if (key.backspace || key.delete) {
|
|
151
|
+
setTextValue((v) => v.slice(0, -1));
|
|
152
|
+
} else if (input && !key.ctrl && !key.meta && input.length === 1) {
|
|
153
|
+
setTextValue((v) => v + input);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
30
156
|
});
|
|
31
|
-
|
|
157
|
+
let hasRecommended = false;
|
|
158
|
+
return /* @__PURE__ */ jsxs2(Box4, { flexDirection: "column", paddingX: 1, children: [
|
|
159
|
+
options.map((opt, i) => {
|
|
160
|
+
const isHighlighted = cursor === i;
|
|
161
|
+
const pointer = isHighlighted ? "\u276F " : " ";
|
|
162
|
+
let label = opt.label;
|
|
163
|
+
if (opt.recommended && !hasRecommended) {
|
|
164
|
+
label += " (Recommended)";
|
|
165
|
+
hasRecommended = true;
|
|
166
|
+
}
|
|
167
|
+
return /* @__PURE__ */ jsxs2(Box4, { children: [
|
|
168
|
+
/* @__PURE__ */ jsxs2(Text4, { color: isHighlighted ? "cyan" : void 0, children: [
|
|
169
|
+
pointer,
|
|
170
|
+
label
|
|
171
|
+
] }),
|
|
172
|
+
opt.description && /* @__PURE__ */ jsxs2(Text4, { dimColor: true, children: [
|
|
173
|
+
" \u2014 ",
|
|
174
|
+
opt.description
|
|
175
|
+
] })
|
|
176
|
+
] }, opt.label);
|
|
177
|
+
}),
|
|
178
|
+
/* @__PURE__ */ jsxs2(Box4, { marginTop: 1, children: [
|
|
179
|
+
/* @__PURE__ */ jsx4(Text4, { color: isOnTextField ? "cyan" : void 0, children: isOnTextField ? "\u276F " : " " }),
|
|
180
|
+
isOnTextField ? /* @__PURE__ */ jsxs2(Text4, { children: [
|
|
181
|
+
/* @__PURE__ */ jsx4(Text4, { color: "cyan", children: textValue }),
|
|
182
|
+
/* @__PURE__ */ jsx4(Text4, { color: "cyan", children: "\u258A" }),
|
|
183
|
+
!textValue && /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: " Type a question or suggestion..." })
|
|
184
|
+
] }) : /* @__PURE__ */ jsx4(Text4, { dimColor: true, children: "Type a question or suggestion..." })
|
|
185
|
+
] })
|
|
186
|
+
] });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// src/cli/components/stage-list.tsx
|
|
190
|
+
import { useState as useState3 } from "react";
|
|
191
|
+
import { Box as Box5, Text as Text5, useInput as useInput2 } from "ink";
|
|
192
|
+
import { ConfirmInput } from "@inkjs/ui";
|
|
193
|
+
|
|
194
|
+
// src/agent/progress.ts
|
|
195
|
+
function createProgress() {
|
|
196
|
+
return {
|
|
197
|
+
projectName: null,
|
|
198
|
+
description: null,
|
|
199
|
+
frontend: null,
|
|
200
|
+
backend: null,
|
|
201
|
+
database: null,
|
|
202
|
+
auth: null,
|
|
203
|
+
payments: null,
|
|
204
|
+
ai: null,
|
|
205
|
+
deployment: null,
|
|
206
|
+
extras: []
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
function setDecision(progress, category, choice) {
|
|
210
|
+
if (category === "extras") {
|
|
211
|
+
return { ...progress, extras: [...progress.extras, choice] };
|
|
212
|
+
}
|
|
213
|
+
return { ...progress, [category]: choice };
|
|
214
|
+
}
|
|
215
|
+
function clearDecision(progress, category) {
|
|
216
|
+
if (category === "extras") {
|
|
217
|
+
return { ...progress, extras: [] };
|
|
218
|
+
}
|
|
219
|
+
return { ...progress, [category]: null };
|
|
220
|
+
}
|
|
221
|
+
function clearProjectInfo(progress) {
|
|
222
|
+
return { ...progress, projectName: null, description: null };
|
|
223
|
+
}
|
|
224
|
+
function isComplete(progress) {
|
|
225
|
+
return progress.projectName !== null && progress.description !== null && progress.frontend !== null && progress.database !== null && progress.deployment !== null;
|
|
226
|
+
}
|
|
227
|
+
function formatChoice(choice) {
|
|
228
|
+
if (choice === null) return "not yet decided";
|
|
229
|
+
return choice.component;
|
|
230
|
+
}
|
|
231
|
+
function serializeProgress(progress) {
|
|
232
|
+
const lines = [
|
|
233
|
+
`Project Name: ${progress.projectName ?? "not yet decided"}`,
|
|
234
|
+
`Description: ${progress.description ?? "not yet decided"}`,
|
|
235
|
+
`Frontend: ${formatChoice(progress.frontend)}`,
|
|
236
|
+
`Backend: ${formatChoice(progress.backend)}`,
|
|
237
|
+
`Database: ${formatChoice(progress.database)}`,
|
|
238
|
+
`Auth: ${formatChoice(progress.auth)}`,
|
|
239
|
+
`Payments: ${formatChoice(progress.payments)}`,
|
|
240
|
+
`AI/LLM: ${formatChoice(progress.ai)}`,
|
|
241
|
+
`Deployment: ${formatChoice(progress.deployment)}`,
|
|
242
|
+
`Extras: ${progress.extras.length > 0 ? progress.extras.map((e) => e.component).join(", ") : "not yet decided"}`
|
|
243
|
+
];
|
|
244
|
+
return lines.join("\n");
|
|
245
|
+
}
|
|
246
|
+
function serializeSession(session) {
|
|
247
|
+
return JSON.stringify(session, null, 2);
|
|
248
|
+
}
|
|
249
|
+
function deserializeSession(json) {
|
|
250
|
+
try {
|
|
251
|
+
const data = JSON.parse(json);
|
|
252
|
+
if (data.version !== 1) return null;
|
|
253
|
+
if (!data.progress || !Array.isArray(data.stages) || !Array.isArray(data.messages)) return null;
|
|
254
|
+
return data;
|
|
255
|
+
} catch {
|
|
32
256
|
return null;
|
|
33
257
|
}
|
|
34
|
-
return result;
|
|
35
258
|
}
|
|
36
|
-
|
|
37
|
-
|
|
259
|
+
|
|
260
|
+
// src/cli/components/stage-list.tsx
|
|
261
|
+
import { jsx as jsx5, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
262
|
+
function StageListView({ stages, currentStageId, progress, onResult }) {
|
|
263
|
+
const [showConfirm, setShowConfirm] = useState3(false);
|
|
264
|
+
const [showWarning, setShowWarning] = useState3("");
|
|
265
|
+
const [cursor, setCursor] = useState3(0);
|
|
266
|
+
const canBuild = isComplete(progress);
|
|
267
|
+
const itemCount = stages.length + 1;
|
|
268
|
+
useInput2((input, key) => {
|
|
269
|
+
if (showConfirm) return;
|
|
270
|
+
if (key.upArrow) {
|
|
271
|
+
setCursor((c) => Math.max(0, c - 1));
|
|
272
|
+
setShowWarning("");
|
|
273
|
+
}
|
|
274
|
+
if (key.downArrow) {
|
|
275
|
+
setCursor((c) => Math.min(itemCount - 1, c + 1));
|
|
276
|
+
setShowWarning("");
|
|
277
|
+
}
|
|
278
|
+
if (key.return) {
|
|
279
|
+
if (cursor < stages.length) {
|
|
280
|
+
onResult({ kind: "select", stageId: stages[cursor].id });
|
|
281
|
+
} else {
|
|
282
|
+
if (canBuild) {
|
|
283
|
+
setShowConfirm(true);
|
|
284
|
+
} else {
|
|
285
|
+
const missing = getMissingDecisions(progress);
|
|
286
|
+
setShowWarning(`Complete ${missing.join(", ")} first.`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (key.escape) {
|
|
291
|
+
onResult({ kind: "cancel" });
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
if (showConfirm) {
|
|
295
|
+
return /* @__PURE__ */ jsxs3(Box5, { flexDirection: "column", paddingX: 1, children: [
|
|
296
|
+
/* @__PURE__ */ jsx5(Text5, { bold: true, children: "Ready to build this stack?" }),
|
|
297
|
+
/* @__PURE__ */ jsx5(Box5, { marginTop: 1, children: /* @__PURE__ */ jsx5(
|
|
298
|
+
ConfirmInput,
|
|
299
|
+
{
|
|
300
|
+
defaultChoice: "confirm",
|
|
301
|
+
onConfirm: () => onResult({ kind: "build" }),
|
|
302
|
+
onCancel: () => setShowConfirm(false)
|
|
303
|
+
}
|
|
304
|
+
) })
|
|
305
|
+
] });
|
|
306
|
+
}
|
|
307
|
+
return /* @__PURE__ */ jsxs3(Box5, { flexDirection: "column", paddingX: 1, children: [
|
|
308
|
+
showWarning && /* @__PURE__ */ jsx5(Box5, { marginBottom: 1, children: /* @__PURE__ */ jsx5(Text5, { color: "yellow", children: showWarning }) }),
|
|
309
|
+
stages.map((stage, i) => {
|
|
310
|
+
const isHighlighted = cursor === i;
|
|
311
|
+
const pointer = isHighlighted ? "\u276F " : " ";
|
|
312
|
+
return /* @__PURE__ */ jsxs3(Box5, { children: [
|
|
313
|
+
/* @__PURE__ */ jsx5(Text5, { children: pointer }),
|
|
314
|
+
/* @__PURE__ */ jsx5(StageLabel, { stage, isCurrent: stage.id === currentStageId })
|
|
315
|
+
] }, stage.id);
|
|
316
|
+
}),
|
|
317
|
+
/* @__PURE__ */ jsxs3(Box5, { marginTop: 1, children: [
|
|
318
|
+
/* @__PURE__ */ jsx5(Text5, { children: cursor === stages.length ? "\u276F " : " " }),
|
|
319
|
+
/* @__PURE__ */ jsx5(Text5, { color: canBuild ? "green" : "yellow", bold: cursor === stages.length, children: "\u2605 Build" }),
|
|
320
|
+
!canBuild && /* @__PURE__ */ jsxs3(Text5, { dimColor: true, children: [
|
|
321
|
+
" (",
|
|
322
|
+
requiredRemaining(progress),
|
|
323
|
+
" remaining)"
|
|
324
|
+
] })
|
|
325
|
+
] })
|
|
326
|
+
] });
|
|
327
|
+
}
|
|
328
|
+
function StageLabel({ stage, isCurrent }) {
|
|
329
|
+
if (stage.status === "complete" && stage.confirmed) {
|
|
330
|
+
return /* @__PURE__ */ jsxs3(Text5, { children: [
|
|
331
|
+
/* @__PURE__ */ jsxs3(Text5, { color: "green", children: [
|
|
332
|
+
"\u2713 ",
|
|
333
|
+
stage.label
|
|
334
|
+
] }),
|
|
335
|
+
/* @__PURE__ */ jsxs3(Text5, { dimColor: true, children: [
|
|
336
|
+
" \u2014 ",
|
|
337
|
+
stage.summary
|
|
338
|
+
] })
|
|
339
|
+
] });
|
|
340
|
+
}
|
|
341
|
+
if (stage.status === "complete" && !stage.confirmed) {
|
|
342
|
+
return /* @__PURE__ */ jsxs3(Text5, { children: [
|
|
343
|
+
/* @__PURE__ */ jsxs3(Text5, { color: "yellow", children: [
|
|
344
|
+
"\u25C6 ",
|
|
345
|
+
stage.label
|
|
346
|
+
] }),
|
|
347
|
+
/* @__PURE__ */ jsxs3(Text5, { color: "yellow", dimColor: true, children: [
|
|
348
|
+
" \u2014 ",
|
|
349
|
+
stage.summary,
|
|
350
|
+
" (suggested)"
|
|
351
|
+
] })
|
|
352
|
+
] });
|
|
353
|
+
}
|
|
354
|
+
if (stage.status === "skipped") {
|
|
355
|
+
return /* @__PURE__ */ jsxs3(Text5, { dimColor: true, children: [
|
|
356
|
+
"\u2013 ",
|
|
357
|
+
stage.label,
|
|
358
|
+
" \u2014 skipped"
|
|
359
|
+
] });
|
|
360
|
+
}
|
|
361
|
+
if (isCurrent) {
|
|
362
|
+
return /* @__PURE__ */ jsxs3(Text5, { children: [
|
|
363
|
+
/* @__PURE__ */ jsxs3(Text5, { color: "cyan", bold: true, children: [
|
|
364
|
+
"\u25CF ",
|
|
365
|
+
stage.label
|
|
366
|
+
] }),
|
|
367
|
+
/* @__PURE__ */ jsx5(Text5, { color: "cyan", children: " \u2190 needs your input" })
|
|
368
|
+
] });
|
|
369
|
+
}
|
|
370
|
+
return /* @__PURE__ */ jsxs3(Text5, { dimColor: true, children: [
|
|
371
|
+
"\u25CB ",
|
|
372
|
+
stage.label
|
|
373
|
+
] });
|
|
374
|
+
}
|
|
375
|
+
function requiredRemaining(progress) {
|
|
376
|
+
let count = 0;
|
|
377
|
+
if (!progress.projectName) count++;
|
|
378
|
+
if (!progress.description) count++;
|
|
379
|
+
if (!progress.frontend) count++;
|
|
380
|
+
if (!progress.database) count++;
|
|
381
|
+
if (!progress.deployment) count++;
|
|
382
|
+
return count;
|
|
38
383
|
}
|
|
39
|
-
function
|
|
40
|
-
|
|
384
|
+
function getMissingDecisions(progress) {
|
|
385
|
+
const missing = [];
|
|
386
|
+
if (!progress.projectName || !progress.description) missing.push("Project Info");
|
|
387
|
+
if (!progress.frontend) missing.push("Frontend");
|
|
388
|
+
if (!progress.database) missing.push("Database");
|
|
389
|
+
if (!progress.deployment) missing.push("Deployment");
|
|
390
|
+
return missing;
|
|
41
391
|
}
|
|
42
|
-
|
|
43
|
-
|
|
392
|
+
|
|
393
|
+
// src/cli/app.tsx
|
|
394
|
+
import { TextInput as TextInput2, Spinner as Spinner2 } from "@inkjs/ui";
|
|
395
|
+
|
|
396
|
+
// src/cli/components/project-info-form.tsx
|
|
397
|
+
import { useState as useState4 } from "react";
|
|
398
|
+
import { Box as Box6, Text as Text6 } from "ink";
|
|
399
|
+
import { TextInput } from "@inkjs/ui";
|
|
400
|
+
import { jsx as jsx6, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
401
|
+
function ProjectInfoForm({ onSubmit }) {
|
|
402
|
+
const [field, setField] = useState4("name");
|
|
403
|
+
const [name, setName] = useState4("");
|
|
404
|
+
return /* @__PURE__ */ jsxs4(Box6, { flexDirection: "column", children: [
|
|
405
|
+
/* @__PURE__ */ jsx6(Text6, { bold: true, children: "Let's set up your project" }),
|
|
406
|
+
/* @__PURE__ */ jsx6(Text6, { children: " " }),
|
|
407
|
+
field === "name" && /* @__PURE__ */ jsxs4(Box6, { children: [
|
|
408
|
+
/* @__PURE__ */ jsx6(Text6, { color: "cyan", children: "Project name: " }),
|
|
409
|
+
/* @__PURE__ */ jsx6(
|
|
410
|
+
TextInput,
|
|
411
|
+
{
|
|
412
|
+
placeholder: "my-app",
|
|
413
|
+
onSubmit: (value) => {
|
|
414
|
+
if (value.trim()) {
|
|
415
|
+
setName(value.trim());
|
|
416
|
+
setField("description");
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
)
|
|
421
|
+
] }),
|
|
422
|
+
field === "description" && /* @__PURE__ */ jsxs4(Box6, { flexDirection: "column", children: [
|
|
423
|
+
/* @__PURE__ */ jsxs4(Box6, { children: [
|
|
424
|
+
/* @__PURE__ */ jsx6(Text6, { color: "green", children: "\u2713 Project name: " }),
|
|
425
|
+
/* @__PURE__ */ jsx6(Text6, { children: name })
|
|
426
|
+
] }),
|
|
427
|
+
/* @__PURE__ */ jsxs4(Box6, { children: [
|
|
428
|
+
/* @__PURE__ */ jsx6(Text6, { color: "cyan", children: "What are you building: " }),
|
|
429
|
+
/* @__PURE__ */ jsx6(
|
|
430
|
+
TextInput,
|
|
431
|
+
{
|
|
432
|
+
placeholder: "a task management SaaS",
|
|
433
|
+
onSubmit: (value) => {
|
|
434
|
+
if (value.trim()) {
|
|
435
|
+
onSubmit(name, value.trim());
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
)
|
|
440
|
+
] })
|
|
441
|
+
] }),
|
|
442
|
+
/* @__PURE__ */ jsx6(Text6, { children: " " }),
|
|
443
|
+
/* @__PURE__ */ jsx6(Text6, { dimColor: true, children: field === "name" ? "Enter a name for your project" : "Briefly describe what you're building" })
|
|
444
|
+
] });
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// src/cli/bridge.ts
|
|
448
|
+
function createBridge() {
|
|
449
|
+
const listeners = /* @__PURE__ */ new Map();
|
|
450
|
+
let pendingResolve = null;
|
|
451
|
+
function emit(event, ...args2) {
|
|
452
|
+
const set = listeners.get(event);
|
|
453
|
+
if (set) {
|
|
454
|
+
for (const fn of set) fn(...args2);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return {
|
|
458
|
+
onStreamText: (delta) => emit("streamText", delta),
|
|
459
|
+
onStreamEnd: (fullText) => emit("streamEnd", fullText),
|
|
460
|
+
onPresentOptions: (options) => emit("presentOptions", options),
|
|
461
|
+
onSpinnerStart: () => emit("spinnerStart"),
|
|
462
|
+
onStageComplete: (summary) => emit("stageComplete", summary),
|
|
463
|
+
onError: (error) => emit("error", error),
|
|
464
|
+
waitForInput: () => new Promise((resolve2) => {
|
|
465
|
+
pendingResolve = resolve2;
|
|
466
|
+
}),
|
|
467
|
+
resolveInput: (result) => {
|
|
468
|
+
if (pendingResolve) {
|
|
469
|
+
const resolve2 = pendingResolve;
|
|
470
|
+
pendingResolve = null;
|
|
471
|
+
resolve2(result);
|
|
472
|
+
}
|
|
473
|
+
},
|
|
474
|
+
subscribe: (event, listener) => {
|
|
475
|
+
if (!listeners.has(event)) listeners.set(event, /* @__PURE__ */ new Set());
|
|
476
|
+
listeners.get(event).add(listener);
|
|
477
|
+
return () => {
|
|
478
|
+
listeners.get(event)?.delete(listener);
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
};
|
|
44
482
|
}
|
|
45
483
|
|
|
46
484
|
// src/agent/loop.ts
|
|
@@ -48,6 +486,22 @@ import { join as join2 } from "path";
|
|
|
48
486
|
|
|
49
487
|
// src/llm/client.ts
|
|
50
488
|
import Anthropic from "@anthropic-ai/sdk";
|
|
489
|
+
|
|
490
|
+
// src/util/logger.ts
|
|
491
|
+
import pino from "pino";
|
|
492
|
+
var logger = pino({
|
|
493
|
+
level: process.env.LOG_LEVEL ?? "info",
|
|
494
|
+
transport: process.env.NODE_ENV !== "production" ? { target: "pino-pretty", options: { destination: 2 } } : void 0
|
|
495
|
+
}, process.env.NODE_ENV === "production" ? pino.destination(2) : void 0);
|
|
496
|
+
function createLogger(component) {
|
|
497
|
+
return logger.child({ component });
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// src/llm/client.ts
|
|
501
|
+
var log = createLogger("llm");
|
|
502
|
+
function summarizeMessages(messages) {
|
|
503
|
+
return `${messages.length} messages, last: ${messages.at(-1)?.role ?? "none"}`;
|
|
504
|
+
}
|
|
51
505
|
function getClient() {
|
|
52
506
|
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
53
507
|
if (!apiKey) {
|
|
@@ -66,6 +520,8 @@ function client() {
|
|
|
66
520
|
}
|
|
67
521
|
async function chat(options) {
|
|
68
522
|
const { system, messages, tools, maxTokens, mcpServers } = options;
|
|
523
|
+
log.debug({ maxTokens, toolCount: tools?.length ?? 0, messages: summarizeMessages(messages) }, "chat request");
|
|
524
|
+
let response;
|
|
69
525
|
if (mcpServers && Object.keys(mcpServers).length > 0) {
|
|
70
526
|
const mcpServerList = Object.entries(mcpServers).map(([name, config]) => ({
|
|
71
527
|
type: "url",
|
|
@@ -75,13 +531,13 @@ async function chat(options) {
|
|
|
75
531
|
authorization_token: config.apiKey
|
|
76
532
|
}
|
|
77
533
|
}));
|
|
78
|
-
|
|
534
|
+
response = await client().beta.messages.create(
|
|
79
535
|
{
|
|
80
536
|
model: "claude-sonnet-4-6",
|
|
81
537
|
max_tokens: maxTokens,
|
|
82
538
|
system,
|
|
83
539
|
messages,
|
|
84
|
-
tools,
|
|
540
|
+
...tools && tools.length > 0 && { tools },
|
|
85
541
|
mcp_servers: mcpServerList
|
|
86
542
|
},
|
|
87
543
|
{
|
|
@@ -90,28 +546,43 @@ async function chat(options) {
|
|
|
90
546
|
}
|
|
91
547
|
}
|
|
92
548
|
);
|
|
549
|
+
} else {
|
|
550
|
+
response = await client().messages.create({
|
|
551
|
+
model: "claude-sonnet-4-6",
|
|
552
|
+
max_tokens: maxTokens,
|
|
553
|
+
system,
|
|
554
|
+
messages,
|
|
555
|
+
...tools && tools.length > 0 && { tools }
|
|
556
|
+
});
|
|
93
557
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
558
|
+
log.info({
|
|
559
|
+
stopReason: response.stop_reason,
|
|
560
|
+
contentBlocks: response.content.length,
|
|
561
|
+
usage: response.usage
|
|
562
|
+
}, "chat response");
|
|
563
|
+
log.debug({ content: response.content }, "chat response content");
|
|
564
|
+
return response;
|
|
101
565
|
}
|
|
102
566
|
async function chatStream(options, callbacks) {
|
|
103
567
|
const { system, messages, tools, maxTokens } = options;
|
|
568
|
+
log.debug({ maxTokens, toolCount: tools?.length ?? 0, messages: summarizeMessages(messages) }, "chatStream request");
|
|
104
569
|
const stream = client().messages.stream({
|
|
105
570
|
model: "claude-sonnet-4-6",
|
|
106
571
|
max_tokens: maxTokens,
|
|
107
572
|
system,
|
|
108
573
|
messages,
|
|
109
|
-
tools
|
|
574
|
+
...tools && tools.length > 0 && { tools }
|
|
110
575
|
});
|
|
111
|
-
stream.on("text", (
|
|
112
|
-
callbacks.onText(
|
|
576
|
+
stream.on("text", (text) => {
|
|
577
|
+
callbacks.onText(text);
|
|
113
578
|
});
|
|
114
579
|
const finalMessage = await stream.finalMessage();
|
|
580
|
+
log.info({
|
|
581
|
+
stopReason: finalMessage.stop_reason,
|
|
582
|
+
contentBlocks: finalMessage.content.length,
|
|
583
|
+
usage: finalMessage.usage
|
|
584
|
+
}, "chatStream response");
|
|
585
|
+
log.debug({ content: finalMessage.content }, "chatStream response content");
|
|
115
586
|
for (const block of finalMessage.content) {
|
|
116
587
|
if (block.type === "tool_use") {
|
|
117
588
|
callbacks.onToolUse(block);
|
|
@@ -123,47 +594,6 @@ async function chatStream(options, callbacks) {
|
|
|
123
594
|
});
|
|
124
595
|
}
|
|
125
596
|
|
|
126
|
-
// src/agent/progress.ts
|
|
127
|
-
function createProgress() {
|
|
128
|
-
return {
|
|
129
|
-
projectName: null,
|
|
130
|
-
description: null,
|
|
131
|
-
frontend: null,
|
|
132
|
-
backend: null,
|
|
133
|
-
database: null,
|
|
134
|
-
auth: null,
|
|
135
|
-
payments: null,
|
|
136
|
-
ai: null,
|
|
137
|
-
deployment: null,
|
|
138
|
-
extras: []
|
|
139
|
-
};
|
|
140
|
-
}
|
|
141
|
-
function setDecision(progress, category, choice) {
|
|
142
|
-
if (category === "extras") {
|
|
143
|
-
return { ...progress, extras: [...progress.extras, choice] };
|
|
144
|
-
}
|
|
145
|
-
return { ...progress, [category]: choice };
|
|
146
|
-
}
|
|
147
|
-
function formatChoice(choice) {
|
|
148
|
-
if (choice === null) return "not yet decided";
|
|
149
|
-
return choice.component;
|
|
150
|
-
}
|
|
151
|
-
function serializeProgress(progress) {
|
|
152
|
-
const lines = [
|
|
153
|
-
`Project Name: ${progress.projectName ?? "not yet decided"}`,
|
|
154
|
-
`Description: ${progress.description ?? "not yet decided"}`,
|
|
155
|
-
`Frontend: ${formatChoice(progress.frontend)}`,
|
|
156
|
-
`Backend: ${formatChoice(progress.backend)}`,
|
|
157
|
-
`Database: ${formatChoice(progress.database)}`,
|
|
158
|
-
`Auth: ${formatChoice(progress.auth)}`,
|
|
159
|
-
`Payments: ${formatChoice(progress.payments)}`,
|
|
160
|
-
`AI/LLM: ${formatChoice(progress.ai)}`,
|
|
161
|
-
`Deployment: ${formatChoice(progress.deployment)}`,
|
|
162
|
-
`Extras: ${progress.extras.length > 0 ? progress.extras.map((e) => e.component).join(", ") : "not yet decided"}`
|
|
163
|
-
];
|
|
164
|
-
return lines.join("\n");
|
|
165
|
-
}
|
|
166
|
-
|
|
167
597
|
// src/agent/tools.ts
|
|
168
598
|
function conversationToolDefinitions() {
|
|
169
599
|
return [
|
|
@@ -185,15 +615,6 @@ function conversationToolDefinitions() {
|
|
|
185
615
|
reasoning: {
|
|
186
616
|
type: "string",
|
|
187
617
|
description: "Explanation for why this component was chosen."
|
|
188
|
-
},
|
|
189
|
-
scaffoldTool: {
|
|
190
|
-
type: "string",
|
|
191
|
-
description: "Optional CLI scaffold tool to use (e.g. create-next-app)."
|
|
192
|
-
},
|
|
193
|
-
scaffoldArgs: {
|
|
194
|
-
type: "array",
|
|
195
|
-
items: { type: "string" },
|
|
196
|
-
description: "Optional arguments to pass to the scaffold tool."
|
|
197
618
|
}
|
|
198
619
|
},
|
|
199
620
|
required: ["category", "component", "reasoning"]
|
|
@@ -236,12 +657,27 @@ function conversationToolDefinitions() {
|
|
|
236
657
|
}
|
|
237
658
|
},
|
|
238
659
|
{
|
|
239
|
-
name: "
|
|
240
|
-
description: "
|
|
660
|
+
name: "present_options",
|
|
661
|
+
description: "Presents technology options for the user to choose from. The UI renders these as selectable items.",
|
|
241
662
|
input_schema: {
|
|
242
663
|
type: "object",
|
|
243
|
-
properties: {
|
|
244
|
-
|
|
664
|
+
properties: {
|
|
665
|
+
options: {
|
|
666
|
+
type: "array",
|
|
667
|
+
items: {
|
|
668
|
+
type: "object",
|
|
669
|
+
properties: {
|
|
670
|
+
label: { type: "string", description: "Short name of the option (max 30 chars)." },
|
|
671
|
+
description: { type: "string", description: "One-line description (max 80 chars)." },
|
|
672
|
+
recommended: { type: "boolean", description: "Whether this is recommended. At most one true." }
|
|
673
|
+
},
|
|
674
|
+
required: ["label", "description"]
|
|
675
|
+
},
|
|
676
|
+
minItems: 2,
|
|
677
|
+
maxItems: 3
|
|
678
|
+
}
|
|
679
|
+
},
|
|
680
|
+
required: ["options"]
|
|
245
681
|
}
|
|
246
682
|
}
|
|
247
683
|
];
|
|
@@ -288,6 +724,11 @@ function scaffoldToolDefinitions() {
|
|
|
288
724
|
additionalProperties: { type: "string" },
|
|
289
725
|
description: "Map of package names to versions to install as dev dependencies."
|
|
290
726
|
},
|
|
727
|
+
scripts: {
|
|
728
|
+
type: "object",
|
|
729
|
+
additionalProperties: { type: "string" },
|
|
730
|
+
description: "Map of script names to commands to merge into package.json scripts."
|
|
731
|
+
},
|
|
291
732
|
envVars: {
|
|
292
733
|
type: "array",
|
|
293
734
|
items: { type: "string" },
|
|
@@ -304,9 +745,7 @@ function executeConversationTool(name, input, progress, _messages) {
|
|
|
304
745
|
const category = input.category;
|
|
305
746
|
const choice = {
|
|
306
747
|
component: input.component,
|
|
307
|
-
reasoning: input.reasoning
|
|
308
|
-
...input.scaffoldTool !== void 0 && { scaffoldTool: input.scaffoldTool },
|
|
309
|
-
...input.scaffoldArgs !== void 0 && { scaffoldArgs: input.scaffoldArgs }
|
|
748
|
+
reasoning: input.reasoning
|
|
310
749
|
};
|
|
311
750
|
const updatedProgress = setDecision(progress, category, choice);
|
|
312
751
|
return {
|
|
@@ -331,12 +770,8 @@ function executeConversationTool(name, input, progress, _messages) {
|
|
|
331
770
|
response: input.summary
|
|
332
771
|
};
|
|
333
772
|
}
|
|
334
|
-
if (name === "
|
|
335
|
-
return {
|
|
336
|
-
progress,
|
|
337
|
-
response: "Plan is ready to present.",
|
|
338
|
-
signal: "present_plan"
|
|
339
|
-
};
|
|
773
|
+
if (name === "present_options") {
|
|
774
|
+
return { progress, response: "Options presented to user." };
|
|
340
775
|
}
|
|
341
776
|
return {
|
|
342
777
|
progress,
|
|
@@ -344,25 +779,65 @@ function executeConversationTool(name, input, progress, _messages) {
|
|
|
344
779
|
};
|
|
345
780
|
}
|
|
346
781
|
|
|
782
|
+
// src/agent/stages.ts
|
|
783
|
+
var DEFAULT_STAGES = [
|
|
784
|
+
{ id: "project_info", label: "Project Info", status: "pending", progressKeys: ["projectName", "description"] },
|
|
785
|
+
{ id: "frontend", label: "Frontend", status: "pending", progressKeys: ["frontend"] },
|
|
786
|
+
{ id: "backend", label: "Backend", status: "pending", progressKeys: ["backend"] },
|
|
787
|
+
{ id: "database", label: "Database", status: "pending", progressKeys: ["database"] },
|
|
788
|
+
{ id: "auth", label: "Auth", status: "pending", progressKeys: ["auth"] },
|
|
789
|
+
{ id: "payments", label: "Payments", status: "pending", progressKeys: ["payments"] },
|
|
790
|
+
{ id: "ai", label: "AI/LLM", status: "pending", progressKeys: ["ai"] },
|
|
791
|
+
{ id: "deployment", label: "Deployment", status: "pending", progressKeys: ["deployment"] },
|
|
792
|
+
{ id: "extras", label: "Extras", status: "pending", progressKeys: ["extras"] }
|
|
793
|
+
];
|
|
794
|
+
var STAGE_INSTRUCTIONS = {
|
|
795
|
+
project_info: "Ask for the project name and a brief description of what the user is building. Call set_project_info to record them.",
|
|
796
|
+
frontend: "Present 2-3 frontend framework options with trade-offs and your recommendation. Consider the project description when suggesting options.",
|
|
797
|
+
backend: "Present 2-3 backend/API options. Consider the chosen frontend \u2014 if it has built-in API routes (e.g., Next.js), that may be sufficient. If this stage is not needed, explain why and skip it.",
|
|
798
|
+
database: "Present 2-3 database options with ORM/query layer recommendations. Consider the chosen frontend and backend when suggesting options.",
|
|
799
|
+
auth: "Present 2-3 authentication options. If auth is not needed for this project, explain why and skip it.",
|
|
800
|
+
payments: "Present 2-3 payment processing options. If payments are not needed, explain why and skip it.",
|
|
801
|
+
ai: "Present 2-3 AI/LLM integration options. If AI is not needed, explain why and skip it.",
|
|
802
|
+
deployment: "Present 2-3 deployment platform options. Consider the chosen frontend and backend when suggesting options.",
|
|
803
|
+
extras: "Suggest any additional integrations that would benefit this project (analytics, email, monitoring, etc.). If none are needed, explain why and skip it."
|
|
804
|
+
};
|
|
805
|
+
|
|
347
806
|
// src/agent/system-prompt.ts
|
|
348
|
-
function buildConversationPrompt(progress) {
|
|
807
|
+
function buildConversationPrompt(progress, stageId, stages) {
|
|
808
|
+
const stage = stages.find((s) => s.id === stageId);
|
|
809
|
+
const stageLabel = stage?.label ?? stageId;
|
|
810
|
+
const instruction = STAGE_INSTRUCTIONS[stageId] ?? `Discuss the ${stageLabel} stage with the user.`;
|
|
811
|
+
const completedSummaries = stages.filter((s) => s.status === "complete" && s.summary).map((s) => `- ${s.label}: ${s.summary}`).join("\n");
|
|
812
|
+
const contextSection = completedSummaries ? `
|
|
813
|
+
|
|
814
|
+
Context from previous stages:
|
|
815
|
+
${completedSummaries}` : "";
|
|
349
816
|
return `You are a senior software architect helping a developer set up a new project.
|
|
350
817
|
|
|
351
|
-
|
|
818
|
+
Current project state:
|
|
819
|
+
${serializeProgress(progress)}
|
|
352
820
|
|
|
353
|
-
|
|
354
|
-
- Present 2-3 concrete options per category, plus a "something else" option. Number them (1, 2, 3...) so users can respond quickly.
|
|
355
|
-
- For each set of options, explicitly label your top pick with "(Recommended)" next to it and explain WHY it's the best fit for this specific project. Example: "1. Next.js (Recommended) \u2014 server components, built-in API routes...". Then briefly describe the alternatives and their trade-offs. Be opinionated \u2014 you are a senior architect, not a menu.
|
|
356
|
-
- Keep the conversation focused and friendly. Ask one category at a time.
|
|
357
|
-
- When the user decides on something, call \`set_decision\` to commit that decision before moving on.
|
|
358
|
-
- Start by asking for a project name and a brief description of what they're building. Call \`set_project_info\` to record these before moving to stack decisions.
|
|
359
|
-
- As conversations get long, call \`summarize_stage\` when completing each category to keep context manageable.
|
|
360
|
-
- Once all decisions are made (frontend, database, and deployment are required; backend, auth, payments, and extras are optional), call \`present_plan\` to signal the plan is ready.
|
|
821
|
+
## Current Stage: ${stageLabel}
|
|
361
822
|
|
|
362
|
-
|
|
823
|
+
You are currently discussing the ${stageLabel} stage.
|
|
824
|
+
${instruction}
|
|
363
825
|
|
|
364
|
-
|
|
365
|
-
|
|
826
|
+
Response guidelines:
|
|
827
|
+
- When presenting technology choices, call \`present_options\` with 2-3 options. Do NOT write numbered lists in text.
|
|
828
|
+
- Option labels: max 30 characters (just the name).
|
|
829
|
+
- Option descriptions: max 80 characters (one-line trade-off summary).
|
|
830
|
+
- Mark at most one option as recommended.
|
|
831
|
+
- After a user selects an option, confirm in one short sentence (max 60 chars) and call set_decision immediately.
|
|
832
|
+
- When answering questions, keep responses under 500 characters. Most answers should be 1-2 sentences. Only approach 500 chars for genuinely complex comparisons.
|
|
833
|
+
- Never congratulate or explain why a choice is great. Just confirm and move on.
|
|
834
|
+
${contextSection}
|
|
835
|
+
|
|
836
|
+
Guidelines:
|
|
837
|
+
- Focus on ${stageLabel}. Do not discuss other undecided stages.
|
|
838
|
+
- When the user has made their choice, call \`set_decision\` to commit it, then call \`summarize_stage\` to summarize what was decided.
|
|
839
|
+
- If this stage is not relevant to the project, briefly explain why and call \`summarize_stage\` to skip it.
|
|
840
|
+
- Do not ask the user to confirm each tool call \u2014 just make the calls naturally as decisions are reached.`;
|
|
366
841
|
}
|
|
367
842
|
function buildScaffoldPrompt(progress) {
|
|
368
843
|
return `You are scaffolding a new software project based on an approved plan.
|
|
@@ -375,6 +850,26 @@ Instructions:
|
|
|
375
850
|
2. After scaffolding, call \`add_integration\` for each integration (database, auth, payments, deployment config, extras) to write necessary files, install dependencies, and declare required environment variables.
|
|
376
851
|
3. Use MCP tools to look up current documentation for any libraries or frameworks you integrate, ensuring you use up-to-date APIs and configuration patterns.
|
|
377
852
|
4. Generate complete, working code \u2014 no stubs, no placeholders, no TODO comments. Every file should be production-ready.
|
|
853
|
+
5. Generate a \`deploy.sh\` script at the project root via \`add_integration\`. The script must:
|
|
854
|
+
- Start with \`set -euo pipefail\`
|
|
855
|
+
- Check that the required CLI tool is installed (exit 1 with a helpful message if not)
|
|
856
|
+
- Check authentication status (exit 1 with auth instructions if not)
|
|
857
|
+
- Print what it is about to do
|
|
858
|
+
- Execute the deploy command for the chosen platform
|
|
859
|
+
- Be 15-30 lines max \u2014 no elaborate bash framework
|
|
860
|
+
Use the \`scripts\` property of \`add_integration\` to add \`"deploy": "bash deploy.sh"\` to package.json.
|
|
861
|
+
6. As the LAST \`add_integration\` call, generate a comprehensive \`README.md\` with these sections:
|
|
862
|
+
- **Project title and description**
|
|
863
|
+
- **Tech stack overview** \u2014 what was chosen and why
|
|
864
|
+
- **Prerequisites** \u2014 Node.js version, required CLI tools
|
|
865
|
+
- **Local development setup** \u2014 clone, \`npm install\`, configure \`.env\` from \`.env.example\`, \`npm run dev\`
|
|
866
|
+
- **Environment variables** \u2014 table with: variable name, what it is for, where to get it, required vs optional. Clearly state that \`.env\` is for local development only.
|
|
867
|
+
- **Deployment** \u2014 platform-specific instructions:
|
|
868
|
+
- How to install and authenticate the platform CLI
|
|
869
|
+
- How to set env vars ON THE PLATFORM (e.g. \`vercel env add\`, \`gcloud run services update --set-env-vars\`, AWS Parameter Store). Production env vars are NOT set via \`.env\` \u2014 they are configured through platform-native tools.
|
|
870
|
+
- The deploy command: \`npm run deploy\`
|
|
871
|
+
- Post-deploy verification steps
|
|
872
|
+
- **Project structure** \u2014 brief description of key directories and files
|
|
378
873
|
|
|
379
874
|
Do not ask for confirmation. Proceed through all steps automatically.`;
|
|
380
875
|
}
|
|
@@ -383,73 +878,7 @@ Do not ask for confirmation. Proceed through all steps automatically.`;
|
|
|
383
878
|
import { execFileSync } from "child_process";
|
|
384
879
|
import { readdirSync, existsSync } from "fs";
|
|
385
880
|
import { join } from "path";
|
|
386
|
-
|
|
387
|
-
"create-next-app",
|
|
388
|
-
"create-vite",
|
|
389
|
-
"create-remix",
|
|
390
|
-
"create-svelte",
|
|
391
|
-
"create-astro",
|
|
392
|
-
"nuxi"
|
|
393
|
-
]);
|
|
394
|
-
var TOOL_FLAG_ALLOWLISTS = {
|
|
395
|
-
"create-next-app": /* @__PURE__ */ new Set([
|
|
396
|
-
"--typescript",
|
|
397
|
-
"--js",
|
|
398
|
-
"--tailwind",
|
|
399
|
-
"--no-tailwind",
|
|
400
|
-
"--eslint",
|
|
401
|
-
"--no-eslint",
|
|
402
|
-
"--app",
|
|
403
|
-
"--src-dir",
|
|
404
|
-
"--no-src-dir",
|
|
405
|
-
"--import-alias",
|
|
406
|
-
"--use-npm",
|
|
407
|
-
"--use-pnpm",
|
|
408
|
-
"--use-yarn",
|
|
409
|
-
"--use-bun"
|
|
410
|
-
]),
|
|
411
|
-
"create-vite": /* @__PURE__ */ new Set(["--template"])
|
|
412
|
-
};
|
|
413
|
-
var URL_SCHEME_RE = /https?:|git\+|file:/i;
|
|
414
|
-
var SHELL_META_RE = /[;&|`$(){}[\]<>!#~*?\n\r]/;
|
|
415
|
-
var WHITESPACE_RE = /\s/;
|
|
416
|
-
function validateScaffoldTool(tool, approvedTool) {
|
|
417
|
-
if (!TOOL_ALLOWLIST.has(tool)) {
|
|
418
|
-
throw new Error(`Scaffold tool not in allowlist: "${tool}"`);
|
|
419
|
-
}
|
|
420
|
-
if (tool !== approvedTool) {
|
|
421
|
-
throw new Error(`Scaffold tool "${tool}" does not match approved tool "${approvedTool}"`);
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
function validateScaffoldArgs(tool, args) {
|
|
425
|
-
const strictAllowlist = TOOL_FLAG_ALLOWLISTS[tool];
|
|
426
|
-
for (const arg of args) {
|
|
427
|
-
if (URL_SCHEME_RE.test(arg)) {
|
|
428
|
-
throw new Error(`Scaffold arg contains a URL scheme: "${arg}"`);
|
|
429
|
-
}
|
|
430
|
-
if (WHITESPACE_RE.test(arg)) {
|
|
431
|
-
throw new Error(`Scaffold arg contains whitespace: "${arg}"`);
|
|
432
|
-
}
|
|
433
|
-
if (SHELL_META_RE.test(arg)) {
|
|
434
|
-
throw new Error(`Scaffold arg contains shell metacharacters: "${arg}"`);
|
|
435
|
-
}
|
|
436
|
-
if (strictAllowlist !== void 0) {
|
|
437
|
-
const isFlag = arg.startsWith("--");
|
|
438
|
-
if (isFlag && !strictAllowlist.has(arg)) {
|
|
439
|
-
throw new Error(`Scaffold arg "${arg}" is not in the allowlist for tool "${tool}"`);
|
|
440
|
-
}
|
|
441
|
-
} else {
|
|
442
|
-
if (!arg.startsWith("--")) {
|
|
443
|
-
throw new Error(
|
|
444
|
-
`Scaffold arg "${arg}" must start with "--" for tool "${tool}"`
|
|
445
|
-
);
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
function runScaffold(tool, args, approvedTool, projectName, cwd) {
|
|
451
|
-
validateScaffoldTool(tool, approvedTool);
|
|
452
|
-
validateScaffoldArgs(tool, args);
|
|
881
|
+
function runScaffold(tool, args2, projectName, cwd) {
|
|
453
882
|
const outputDir = join(cwd, projectName);
|
|
454
883
|
if (existsSync(outputDir)) {
|
|
455
884
|
const entries = readdirSync(outputDir);
|
|
@@ -459,7 +888,7 @@ function runScaffold(tool, args, approvedTool, projectName, cwd) {
|
|
|
459
888
|
);
|
|
460
889
|
}
|
|
461
890
|
}
|
|
462
|
-
const spawnArgs = [`${tool}@latest`, projectName, ...
|
|
891
|
+
const spawnArgs = [`${tool}@latest`, projectName, ...args2];
|
|
463
892
|
const opts = { cwd, stdio: "pipe" };
|
|
464
893
|
execFileSync("npx", spawnArgs, opts);
|
|
465
894
|
return outputDir;
|
|
@@ -481,14 +910,14 @@ function validateFilePaths(projectRoot, files) {
|
|
|
481
910
|
}
|
|
482
911
|
}
|
|
483
912
|
function writeIntegration(projectDir, input) {
|
|
484
|
-
const { files, dependencies, devDependencies, envVars } = input;
|
|
913
|
+
const { files, dependencies, devDependencies, scripts, envVars } = input;
|
|
485
914
|
validateFilePaths(projectDir, files);
|
|
486
915
|
for (const [filePath, content] of Object.entries(files)) {
|
|
487
916
|
const fullPath = resolve(projectDir, filePath);
|
|
488
917
|
mkdirSync(dirname(fullPath), { recursive: true });
|
|
489
918
|
writeFileSync(fullPath, content, "utf8");
|
|
490
919
|
}
|
|
491
|
-
if (dependencies !== void 0 || devDependencies !== void 0) {
|
|
920
|
+
if (dependencies !== void 0 || devDependencies !== void 0 || scripts !== void 0) {
|
|
492
921
|
const pkgPath = resolve(projectDir, "package.json");
|
|
493
922
|
let pkg = {};
|
|
494
923
|
if (existsSync2(pkgPath)) {
|
|
@@ -506,6 +935,12 @@ function writeIntegration(projectDir, input) {
|
|
|
506
935
|
...devDependencies
|
|
507
936
|
};
|
|
508
937
|
}
|
|
938
|
+
if (scripts !== void 0) {
|
|
939
|
+
pkg.scripts = {
|
|
940
|
+
...pkg.scripts,
|
|
941
|
+
...scripts
|
|
942
|
+
};
|
|
943
|
+
}
|
|
509
944
|
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
|
|
510
945
|
}
|
|
511
946
|
if (envVars !== void 0 && envVars.length > 0) {
|
|
@@ -516,15 +951,20 @@ function writeIntegration(projectDir, input) {
|
|
|
516
951
|
}
|
|
517
952
|
|
|
518
953
|
// src/agent/loop.ts
|
|
519
|
-
async function
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
messages.
|
|
954
|
+
async function runStageLoop(stage, manager, bridge, mcpServers) {
|
|
955
|
+
const messages = manager.messages;
|
|
956
|
+
let progress = manager.progress;
|
|
957
|
+
if (messages.length === 0) {
|
|
958
|
+
messages.push({ role: "user", content: "I want to start a new project." });
|
|
959
|
+
}
|
|
960
|
+
let hasCalledSetDecision = false;
|
|
523
961
|
while (true) {
|
|
524
|
-
const system = buildConversationPrompt(progress);
|
|
962
|
+
const system = buildConversationPrompt(progress, stage.id, manager.stages);
|
|
525
963
|
let contentBlocks = [];
|
|
526
964
|
const collectedToolUse = [];
|
|
527
965
|
let hasText = false;
|
|
966
|
+
let fullText = "";
|
|
967
|
+
bridge.onSpinnerStart();
|
|
528
968
|
await chatStream(
|
|
529
969
|
{
|
|
530
970
|
system,
|
|
@@ -535,11 +975,9 @@ async function runConversationLoop(mcpServers) {
|
|
|
535
975
|
},
|
|
536
976
|
{
|
|
537
977
|
onText: (delta) => {
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
}
|
|
542
|
-
writeText(delta);
|
|
978
|
+
hasText = true;
|
|
979
|
+
fullText += delta;
|
|
980
|
+
bridge.onStreamText(delta);
|
|
543
981
|
},
|
|
544
982
|
onToolUse: (block) => {
|
|
545
983
|
collectedToolUse.push(block);
|
|
@@ -550,18 +988,31 @@ async function runConversationLoop(mcpServers) {
|
|
|
550
988
|
}
|
|
551
989
|
);
|
|
552
990
|
if (hasText) {
|
|
553
|
-
|
|
554
|
-
writeLine();
|
|
991
|
+
bridge.onStreamText("\n");
|
|
555
992
|
}
|
|
556
993
|
const toolUseBlocks = collectedToolUse;
|
|
557
994
|
if (toolUseBlocks.length > 0) {
|
|
558
995
|
messages.push({ role: "assistant", content: contentBlocks });
|
|
559
996
|
const toolResults = [];
|
|
560
|
-
let hasPresentPlan = false;
|
|
561
997
|
let hasSummarizeStage = false;
|
|
562
998
|
let summarizeSummary = "";
|
|
999
|
+
let madeDecision = false;
|
|
563
1000
|
for (const block of toolUseBlocks) {
|
|
564
1001
|
const toolBlock = block;
|
|
1002
|
+
if (toolBlock.name === "present_options") {
|
|
1003
|
+
const options = toolBlock.input.options;
|
|
1004
|
+
bridge.onPresentOptions(options);
|
|
1005
|
+
const input = await bridge.waitForInput();
|
|
1006
|
+
toolResults.push({
|
|
1007
|
+
type: "tool_result",
|
|
1008
|
+
tool_use_id: toolBlock.id,
|
|
1009
|
+
content: input.kind === "select" ? `User selected: ${input.value}` : input.kind === "text" ? `User wrote: ${input.value}` : "User cancelled."
|
|
1010
|
+
});
|
|
1011
|
+
if (input.kind === "cancel") {
|
|
1012
|
+
return { outcome: "cancel" };
|
|
1013
|
+
}
|
|
1014
|
+
continue;
|
|
1015
|
+
}
|
|
565
1016
|
const result = executeConversationTool(
|
|
566
1017
|
toolBlock.name,
|
|
567
1018
|
toolBlock.input,
|
|
@@ -569,13 +1020,15 @@ async function runConversationLoop(mcpServers) {
|
|
|
569
1020
|
messages
|
|
570
1021
|
);
|
|
571
1022
|
progress = result.progress;
|
|
1023
|
+
manager.progress = progress;
|
|
572
1024
|
toolResults.push({
|
|
573
1025
|
type: "tool_result",
|
|
574
1026
|
tool_use_id: toolBlock.id,
|
|
575
1027
|
content: result.response
|
|
576
1028
|
});
|
|
577
|
-
if (
|
|
578
|
-
|
|
1029
|
+
if (toolBlock.name === "set_decision" || toolBlock.name === "set_project_info") {
|
|
1030
|
+
madeDecision = true;
|
|
1031
|
+
hasCalledSetDecision = true;
|
|
579
1032
|
}
|
|
580
1033
|
if (toolBlock.name === "summarize_stage") {
|
|
581
1034
|
hasSummarizeStage = true;
|
|
@@ -583,36 +1036,45 @@ async function runConversationLoop(mcpServers) {
|
|
|
583
1036
|
}
|
|
584
1037
|
}
|
|
585
1038
|
messages.push({ role: "user", content: toolResults });
|
|
1039
|
+
if (madeDecision || hasSummarizeStage) {
|
|
1040
|
+
manager.save();
|
|
1041
|
+
}
|
|
586
1042
|
if (hasSummarizeStage) {
|
|
587
1043
|
const lastAssistant = messages[messages.length - 2];
|
|
588
1044
|
const lastUser = messages[messages.length - 1];
|
|
589
1045
|
messages.length = 0;
|
|
590
|
-
messages.push({
|
|
591
|
-
|
|
592
|
-
content: summarizeSummary
|
|
593
|
-
});
|
|
594
|
-
messages.push({
|
|
595
|
-
role: "user",
|
|
596
|
-
content: "[Continuing]"
|
|
597
|
-
});
|
|
1046
|
+
messages.push({ role: "assistant", content: summarizeSummary });
|
|
1047
|
+
messages.push({ role: "user", content: "[Continuing]" });
|
|
598
1048
|
messages.push(lastAssistant);
|
|
599
1049
|
messages.push(lastUser);
|
|
1050
|
+
manager.messages = messages;
|
|
600
1051
|
}
|
|
601
|
-
if (
|
|
602
|
-
|
|
603
|
-
|
|
1052
|
+
if (hasSummarizeStage) {
|
|
1053
|
+
if (hasCalledSetDecision || stage.id === "project_info") {
|
|
1054
|
+
return { outcome: "complete", summary: summarizeSummary };
|
|
1055
|
+
}
|
|
1056
|
+
return { outcome: "skipped" };
|
|
604
1057
|
}
|
|
605
1058
|
continue;
|
|
606
1059
|
}
|
|
607
|
-
|
|
608
|
-
|
|
1060
|
+
bridge.onStreamEnd(fullText);
|
|
1061
|
+
const inputResult = await bridge.waitForInput();
|
|
1062
|
+
if (inputResult.kind === "cancel") return { outcome: "cancel" };
|
|
1063
|
+
if (inputResult.kind === "navigate") return { outcome: "navigate" };
|
|
609
1064
|
messages.push({
|
|
610
1065
|
role: "assistant",
|
|
611
1066
|
content: contentBlocks
|
|
612
1067
|
});
|
|
613
|
-
messages.push({ role: "user", content:
|
|
1068
|
+
messages.push({ role: "user", content: inputResult.value });
|
|
614
1069
|
}
|
|
615
1070
|
}
|
|
1071
|
+
function createSimpleSpinner() {
|
|
1072
|
+
return {
|
|
1073
|
+
start: (msg) => process.stdout.write(` ${msg}...`),
|
|
1074
|
+
stop: (msg) => process.stdout.write(` ${msg}
|
|
1075
|
+
`)
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
616
1078
|
async function runScaffoldLoop(progress, mcpServers) {
|
|
617
1079
|
const messages = [];
|
|
618
1080
|
const system = buildScaffoldPrompt(progress);
|
|
@@ -646,22 +1108,20 @@ async function runScaffoldLoop(progress, mcpServers) {
|
|
|
646
1108
|
const toolBlock = block;
|
|
647
1109
|
toolCallCount++;
|
|
648
1110
|
if (toolCallCount > maxToolCalls) {
|
|
649
|
-
|
|
1111
|
+
console.error(`Tool call limit exceeded (${maxToolCalls}). Stopping scaffold loop.`);
|
|
650
1112
|
return false;
|
|
651
1113
|
}
|
|
652
|
-
const
|
|
1114
|
+
const spinner = createSimpleSpinner();
|
|
653
1115
|
try {
|
|
654
1116
|
if (toolBlock.name === "run_scaffold") {
|
|
655
|
-
|
|
656
|
-
const approvedTool = findApprovedScaffoldTool(progress);
|
|
1117
|
+
spinner.start(`Running scaffold: ${toolBlock.input.tool}`);
|
|
657
1118
|
const outputDir = runScaffold(
|
|
658
1119
|
toolBlock.input.tool,
|
|
659
1120
|
toolBlock.input.args,
|
|
660
|
-
approvedTool,
|
|
661
1121
|
projectName,
|
|
662
1122
|
cwd
|
|
663
1123
|
);
|
|
664
|
-
|
|
1124
|
+
spinner.stop(`Scaffold complete: ${outputDir}`);
|
|
665
1125
|
toolResults.push({
|
|
666
1126
|
type: "tool_result",
|
|
667
1127
|
tool_use_id: toolBlock.id,
|
|
@@ -671,21 +1131,22 @@ async function runScaffoldLoop(progress, mcpServers) {
|
|
|
671
1131
|
const integrationDesc = Object.keys(
|
|
672
1132
|
toolBlock.input.files ?? {}
|
|
673
1133
|
).join(", ");
|
|
674
|
-
|
|
1134
|
+
spinner.start(`Adding integration: ${integrationDesc}`);
|
|
675
1135
|
writeIntegration(projectDir, {
|
|
676
1136
|
files: toolBlock.input.files ?? {},
|
|
677
1137
|
dependencies: toolBlock.input.dependencies,
|
|
678
1138
|
devDependencies: toolBlock.input.devDependencies,
|
|
1139
|
+
scripts: toolBlock.input.scripts,
|
|
679
1140
|
envVars: toolBlock.input.envVars
|
|
680
1141
|
});
|
|
681
|
-
|
|
1142
|
+
spinner.stop("Integration added");
|
|
682
1143
|
toolResults.push({
|
|
683
1144
|
type: "tool_result",
|
|
684
1145
|
tool_use_id: toolBlock.id,
|
|
685
1146
|
content: "Integration written successfully."
|
|
686
1147
|
});
|
|
687
1148
|
} else {
|
|
688
|
-
|
|
1149
|
+
spinner.stop(`Unknown tool: ${toolBlock.name}`);
|
|
689
1150
|
toolResults.push({
|
|
690
1151
|
type: "tool_result",
|
|
691
1152
|
tool_use_id: toolBlock.id,
|
|
@@ -695,7 +1156,7 @@ async function runScaffoldLoop(progress, mcpServers) {
|
|
|
695
1156
|
}
|
|
696
1157
|
} catch (err) {
|
|
697
1158
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
698
|
-
|
|
1159
|
+
spinner.stop(`Error: ${errorMessage}`);
|
|
699
1160
|
toolResults.push({
|
|
700
1161
|
type: "tool_result",
|
|
701
1162
|
tool_use_id: toolBlock.id,
|
|
@@ -707,59 +1168,841 @@ async function runScaffoldLoop(progress, mcpServers) {
|
|
|
707
1168
|
messages.push({ role: "user", content: toolResults });
|
|
708
1169
|
}
|
|
709
1170
|
}
|
|
710
|
-
|
|
1171
|
+
|
|
1172
|
+
// src/agent/recommend.ts
|
|
1173
|
+
var log2 = createLogger("recommend");
|
|
1174
|
+
var RECOMMEND_PROMPT = `You are a senior software architect. Based on the project description, recommend a complete technology stack.
|
|
1175
|
+
|
|
1176
|
+
For each category, provide your recommendation as a JSON object. If a category is not needed for this project, set it to null.
|
|
1177
|
+
|
|
1178
|
+
Respond with ONLY a JSON object in this exact format:
|
|
1179
|
+
{
|
|
1180
|
+
"frontend": { "component": "Next.js", "reasoning": "Best for SaaS with built-in API routes" },
|
|
1181
|
+
"backend": null,
|
|
1182
|
+
"database": { "component": "Postgres + Drizzle", "reasoning": "Relational with great TypeScript support" },
|
|
1183
|
+
"auth": { "component": "Clerk", "reasoning": "Drop-in auth with good DX" },
|
|
1184
|
+
"payments": null,
|
|
1185
|
+
"ai": null,
|
|
1186
|
+
"deployment": { "component": "Vercel", "reasoning": "Native Next.js support" },
|
|
1187
|
+
"extras": null
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
Rules:
|
|
1191
|
+
- Be opinionated. Pick the best option, not the most popular.
|
|
1192
|
+
- Set categories to null if they are genuinely not needed for this project.
|
|
1193
|
+
- Keep reasoning under 80 characters.
|
|
1194
|
+
- component names should be max 30 characters.`;
|
|
1195
|
+
async function getRecommendations(projectName, description) {
|
|
1196
|
+
try {
|
|
1197
|
+
const response = await chat({
|
|
1198
|
+
system: RECOMMEND_PROMPT,
|
|
1199
|
+
messages: [{
|
|
1200
|
+
role: "user",
|
|
1201
|
+
content: `Project: "${projectName}"
|
|
1202
|
+
Description: ${description}`
|
|
1203
|
+
}],
|
|
1204
|
+
maxTokens: 1024
|
|
1205
|
+
});
|
|
1206
|
+
const text = response.content.filter((b) => b.type === "text").map((b) => b.text).join("");
|
|
1207
|
+
log2.info({ rawLength: text.length }, "received recommendation response");
|
|
1208
|
+
log2.debug({ rawText: text }, "recommendation raw text");
|
|
1209
|
+
const jsonStr = text.replace(/^```(?:json)?\s*\n?/m, "").replace(/\n?```\s*$/m, "").trim();
|
|
1210
|
+
const parsed = JSON.parse(jsonStr);
|
|
1211
|
+
const categories = Object.keys(parsed).filter((k) => parsed[k] !== null);
|
|
1212
|
+
log2.info({ recommended: categories, skipped: Object.keys(parsed).filter((k) => parsed[k] === null) }, "parsed recommendations");
|
|
1213
|
+
return parsed;
|
|
1214
|
+
} catch (err) {
|
|
1215
|
+
log2.error({ err }, "recommendation pass failed");
|
|
1216
|
+
return {};
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
function applyRecommendations(progress, stages, recommendations) {
|
|
1220
|
+
let updated = { ...progress };
|
|
711
1221
|
const categories = [
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
1222
|
+
"frontend",
|
|
1223
|
+
"backend",
|
|
1224
|
+
"database",
|
|
1225
|
+
"auth",
|
|
1226
|
+
"payments",
|
|
1227
|
+
"ai",
|
|
1228
|
+
"deployment"
|
|
719
1229
|
];
|
|
720
|
-
for (const
|
|
721
|
-
if (
|
|
722
|
-
|
|
1230
|
+
for (const category of categories) {
|
|
1231
|
+
if (!(category in recommendations)) continue;
|
|
1232
|
+
const rec = recommendations[category];
|
|
1233
|
+
const stage = stages.find((s) => s.id === category);
|
|
1234
|
+
if (!stage) continue;
|
|
1235
|
+
if (rec) {
|
|
1236
|
+
updated = setDecision(updated, category, {
|
|
1237
|
+
component: rec.component,
|
|
1238
|
+
reasoning: rec.reasoning
|
|
1239
|
+
});
|
|
1240
|
+
stage.status = "complete";
|
|
1241
|
+
stage.summary = rec.component;
|
|
1242
|
+
stage.confirmed = false;
|
|
1243
|
+
} else {
|
|
1244
|
+
stage.status = "skipped";
|
|
1245
|
+
stage.summary = "not needed";
|
|
1246
|
+
stage.confirmed = false;
|
|
723
1247
|
}
|
|
724
1248
|
}
|
|
725
|
-
|
|
1249
|
+
const extrasStage = stages.find((s) => s.id === "extras");
|
|
1250
|
+
if (extrasStage && !recommendations["extras"]) {
|
|
1251
|
+
extrasStage.status = "skipped";
|
|
1252
|
+
extrasStage.summary = "none suggested";
|
|
1253
|
+
extrasStage.confirmed = false;
|
|
1254
|
+
}
|
|
1255
|
+
return { progress: updated, stages };
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// src/cli/app.tsx
|
|
1259
|
+
import { jsx as jsx7, jsxs as jsxs5 } from "react/jsx-runtime";
|
|
1260
|
+
function App({ manager, onBuild, onExit }) {
|
|
1261
|
+
const app = useApp();
|
|
1262
|
+
const { width, height } = useScreenSize();
|
|
1263
|
+
const [view, setView] = useState5(
|
|
1264
|
+
manager.progress.projectName ? "stage_list" : "project_info"
|
|
1265
|
+
);
|
|
1266
|
+
const [bridge] = useState5(() => createBridge());
|
|
1267
|
+
const [currentStage, setCurrentStage] = useState5(manager.currentStage());
|
|
1268
|
+
const [progress, setProgress] = useState5(manager.progress);
|
|
1269
|
+
const [stages, setStages] = useState5([...manager.stages]);
|
|
1270
|
+
const [options, setOptions] = useState5([]);
|
|
1271
|
+
const [errorMsg, setErrorMsg] = useState5("");
|
|
1272
|
+
const syncState = useCallback(() => {
|
|
1273
|
+
setProgress({ ...manager.progress });
|
|
1274
|
+
setStages([...manager.stages]);
|
|
1275
|
+
setCurrentStage(manager.currentStage());
|
|
1276
|
+
}, [manager]);
|
|
1277
|
+
useEffect2(() => {
|
|
1278
|
+
const unsubs = [
|
|
1279
|
+
bridge.subscribe("presentOptions", (opts) => {
|
|
1280
|
+
setOptions(opts);
|
|
1281
|
+
setView("options");
|
|
1282
|
+
}),
|
|
1283
|
+
bridge.subscribe("streamEnd", () => {
|
|
1284
|
+
setView("input");
|
|
1285
|
+
}),
|
|
1286
|
+
bridge.subscribe("spinnerStart", () => {
|
|
1287
|
+
setView("conversation");
|
|
1288
|
+
}),
|
|
1289
|
+
bridge.subscribe("error", (err) => {
|
|
1290
|
+
setErrorMsg(err.message);
|
|
1291
|
+
setView("error");
|
|
1292
|
+
})
|
|
1293
|
+
];
|
|
1294
|
+
return () => unsubs.forEach((fn) => fn());
|
|
1295
|
+
}, [bridge]);
|
|
1296
|
+
useInput3((_input, key) => {
|
|
1297
|
+
if (key.escape && view !== "stage_list") {
|
|
1298
|
+
bridge.resolveInput({ kind: "cancel" });
|
|
1299
|
+
setView("stage_list");
|
|
1300
|
+
syncState();
|
|
1301
|
+
}
|
|
1302
|
+
}, { isActive: view === "conversation" || view === "error" });
|
|
1303
|
+
const runStage = useCallback(async (stageId) => {
|
|
1304
|
+
const stage = manager.stages.find((s) => s.id === stageId);
|
|
1305
|
+
if (!stage) return;
|
|
1306
|
+
setCurrentStage(stage);
|
|
1307
|
+
setView("conversation");
|
|
1308
|
+
try {
|
|
1309
|
+
const result = await runStageLoop(stage, manager, bridge);
|
|
1310
|
+
switch (result.outcome) {
|
|
1311
|
+
case "complete":
|
|
1312
|
+
manager.completeStage(stageId, result.summary);
|
|
1313
|
+
const completedStage = manager.stages.find((s) => s.id === stageId);
|
|
1314
|
+
if (completedStage) completedStage.confirmed = true;
|
|
1315
|
+
manager.save();
|
|
1316
|
+
break;
|
|
1317
|
+
case "skipped":
|
|
1318
|
+
manager.skipStage(stageId);
|
|
1319
|
+
manager.save();
|
|
1320
|
+
break;
|
|
1321
|
+
case "cancel":
|
|
1322
|
+
manager.restorePendingNavigation();
|
|
1323
|
+
break;
|
|
1324
|
+
}
|
|
1325
|
+
} catch (err) {
|
|
1326
|
+
setErrorMsg(err.message);
|
|
1327
|
+
setView("error");
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
syncState();
|
|
1331
|
+
setView("stage_list");
|
|
1332
|
+
}, [manager, bridge, syncState]);
|
|
1333
|
+
const handleStageResult = useCallback((result) => {
|
|
1334
|
+
if (result.kind === "select") {
|
|
1335
|
+
const stage = manager.stages.find((s) => s.id === result.stageId);
|
|
1336
|
+
if (stage && (stage.status === "complete" || stage.status === "skipped")) {
|
|
1337
|
+
manager.navigateTo(result.stageId);
|
|
1338
|
+
syncState();
|
|
1339
|
+
}
|
|
1340
|
+
runStage(result.stageId);
|
|
1341
|
+
} else if (result.kind === "build") {
|
|
1342
|
+
onBuild();
|
|
1343
|
+
app.exit();
|
|
1344
|
+
} else if (result.kind === "cancel") {
|
|
1345
|
+
manager.save();
|
|
1346
|
+
onExit();
|
|
1347
|
+
app.exit();
|
|
1348
|
+
}
|
|
1349
|
+
}, [manager, runStage, syncState, onBuild, onExit, app]);
|
|
1350
|
+
const handleOptionSelect = useCallback((result) => {
|
|
1351
|
+
setView("conversation");
|
|
1352
|
+
bridge.resolveInput(result);
|
|
1353
|
+
}, [bridge]);
|
|
1354
|
+
const handleTextSubmit = useCallback((value) => {
|
|
1355
|
+
setView("conversation");
|
|
1356
|
+
bridge.resolveInput({ kind: "text", value });
|
|
1357
|
+
}, [bridge]);
|
|
1358
|
+
const handleProjectInfo = useCallback(async (name, description) => {
|
|
1359
|
+
manager.progress = {
|
|
1360
|
+
...manager.progress,
|
|
1361
|
+
projectName: name,
|
|
1362
|
+
description
|
|
1363
|
+
};
|
|
1364
|
+
const stage = manager.stages.find((s) => s.id === "project_info");
|
|
1365
|
+
if (stage) {
|
|
1366
|
+
stage.status = "complete";
|
|
1367
|
+
stage.confirmed = true;
|
|
1368
|
+
stage.summary = `${name}: ${description}`;
|
|
1369
|
+
}
|
|
1370
|
+
manager.save();
|
|
1371
|
+
syncState();
|
|
1372
|
+
setView("loading");
|
|
1373
|
+
try {
|
|
1374
|
+
const recommendations = await getRecommendations(name, description);
|
|
1375
|
+
const { progress: updatedProgress } = applyRecommendations(
|
|
1376
|
+
manager.progress,
|
|
1377
|
+
manager.stages,
|
|
1378
|
+
recommendations
|
|
1379
|
+
);
|
|
1380
|
+
manager.progress = updatedProgress;
|
|
1381
|
+
manager.save();
|
|
1382
|
+
} catch (err) {
|
|
1383
|
+
console.error("Recommendation pass failed:", err);
|
|
1384
|
+
}
|
|
1385
|
+
syncState();
|
|
1386
|
+
setView("stage_list");
|
|
1387
|
+
}, [manager, syncState]);
|
|
1388
|
+
const footerMode = view === "stage_list" ? "stage_list" : view === "options" ? "options" : view === "input" || view === "project_info" ? "input" : "decisions";
|
|
1389
|
+
const contentHeight = height - 4;
|
|
1390
|
+
return /* @__PURE__ */ jsxs5(Box7, { flexDirection: "column", width, height, children: [
|
|
1391
|
+
/* @__PURE__ */ jsx7(
|
|
1392
|
+
Header,
|
|
1393
|
+
{
|
|
1394
|
+
appName: "stack-agent",
|
|
1395
|
+
currentStage: view === "stage_list" ? null : currentStage,
|
|
1396
|
+
stages,
|
|
1397
|
+
showDots: view !== "stage_list"
|
|
1398
|
+
}
|
|
1399
|
+
),
|
|
1400
|
+
/* @__PURE__ */ jsxs5(Box7, { flexDirection: "column", height: contentHeight, paddingX: 1, borderStyle: "single", borderTop: false, borderBottom: false, children: [
|
|
1401
|
+
view === "project_info" && /* @__PURE__ */ jsx7(ProjectInfoForm, { onSubmit: handleProjectInfo }),
|
|
1402
|
+
view === "loading" && /* @__PURE__ */ jsx7(Box7, { flexDirection: "column", children: /* @__PURE__ */ jsx7(Spinner2, { label: "Analyzing your project and recommending a stack..." }) }),
|
|
1403
|
+
view === "stage_list" && /* @__PURE__ */ jsx7(
|
|
1404
|
+
StageListView,
|
|
1405
|
+
{
|
|
1406
|
+
stages,
|
|
1407
|
+
currentStageId: currentStage?.id ?? null,
|
|
1408
|
+
progress,
|
|
1409
|
+
onResult: handleStageResult
|
|
1410
|
+
}
|
|
1411
|
+
),
|
|
1412
|
+
view === "conversation" && /* @__PURE__ */ jsx7(ConversationView, { bridge, maxLines: contentHeight }),
|
|
1413
|
+
view === "input" && /* @__PURE__ */ jsxs5(Box7, { flexDirection: "column", children: [
|
|
1414
|
+
/* @__PURE__ */ jsx7(ConversationView, { bridge, maxLines: contentHeight - 3 }),
|
|
1415
|
+
/* @__PURE__ */ jsx7(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx7(
|
|
1416
|
+
TextInput2,
|
|
1417
|
+
{
|
|
1418
|
+
placeholder: "Type your response...",
|
|
1419
|
+
onSubmit: handleTextSubmit
|
|
1420
|
+
}
|
|
1421
|
+
) })
|
|
1422
|
+
] }),
|
|
1423
|
+
view === "options" && /* @__PURE__ */ jsxs5(Box7, { flexDirection: "column", children: [
|
|
1424
|
+
/* @__PURE__ */ jsx7(ConversationView, { bridge, maxLines: contentHeight - 8 }),
|
|
1425
|
+
/* @__PURE__ */ jsx7(OptionSelect, { options, onSelect: handleOptionSelect })
|
|
1426
|
+
] }),
|
|
1427
|
+
view === "error" && /* @__PURE__ */ jsxs5(Box7, { flexDirection: "column", children: [
|
|
1428
|
+
/* @__PURE__ */ jsxs5(Text7, { color: "red", bold: true, children: [
|
|
1429
|
+
"Error: ",
|
|
1430
|
+
errorMsg
|
|
1431
|
+
] }),
|
|
1432
|
+
/* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "Press Esc to return to stage list" })
|
|
1433
|
+
] })
|
|
1434
|
+
] }),
|
|
1435
|
+
/* @__PURE__ */ jsx7(
|
|
1436
|
+
Footer,
|
|
1437
|
+
{
|
|
1438
|
+
progress,
|
|
1439
|
+
stages,
|
|
1440
|
+
terminalWidth: width,
|
|
1441
|
+
mode: footerMode
|
|
1442
|
+
}
|
|
1443
|
+
)
|
|
1444
|
+
] });
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
// src/agent/stage-manager.ts
|
|
1448
|
+
import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, renameSync, unlinkSync, existsSync as existsSync3 } from "fs";
|
|
1449
|
+
import { join as join3 } from "path";
|
|
1450
|
+
var SESSION_FILE = ".stack-agent.json";
|
|
1451
|
+
var SESSION_VERSION = 1;
|
|
1452
|
+
var StageManager = class _StageManager {
|
|
1453
|
+
session;
|
|
1454
|
+
filePath;
|
|
1455
|
+
onInvalidate;
|
|
1456
|
+
pendingNavigation;
|
|
1457
|
+
constructor(session, cwd, onInvalidate) {
|
|
1458
|
+
this.session = session;
|
|
1459
|
+
this.filePath = join3(cwd, SESSION_FILE);
|
|
1460
|
+
this.onInvalidate = onInvalidate;
|
|
1461
|
+
}
|
|
1462
|
+
static start(cwd, onInvalidate) {
|
|
1463
|
+
const session = {
|
|
1464
|
+
version: SESSION_VERSION,
|
|
1465
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1466
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1467
|
+
progress: createProgress(),
|
|
1468
|
+
stages: structuredClone(DEFAULT_STAGES),
|
|
1469
|
+
messages: []
|
|
1470
|
+
};
|
|
1471
|
+
return new _StageManager(session, cwd, onInvalidate);
|
|
1472
|
+
}
|
|
1473
|
+
static resume(cwd, onInvalidate) {
|
|
1474
|
+
const session = _StageManager.detect(cwd);
|
|
1475
|
+
if (!session) return null;
|
|
1476
|
+
return new _StageManager(session, cwd, onInvalidate);
|
|
1477
|
+
}
|
|
1478
|
+
static detect(cwd) {
|
|
1479
|
+
const filePath = join3(cwd, SESSION_FILE);
|
|
1480
|
+
if (!existsSync3(filePath)) return null;
|
|
1481
|
+
try {
|
|
1482
|
+
const json = readFileSync2(filePath, "utf-8");
|
|
1483
|
+
return deserializeSession(json);
|
|
1484
|
+
} catch {
|
|
1485
|
+
return null;
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
// --- Stage navigation ---
|
|
1489
|
+
currentStage() {
|
|
1490
|
+
return this.session.stages.find((s) => s.status === "pending") ?? null;
|
|
1491
|
+
}
|
|
1492
|
+
completeStage(id, summary) {
|
|
1493
|
+
const stage = this.session.stages.find((s) => s.id === id);
|
|
1494
|
+
if (!stage) return;
|
|
1495
|
+
stage.status = "complete";
|
|
1496
|
+
stage.summary = summary;
|
|
1497
|
+
this.session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1498
|
+
if (this.pendingNavigation?.stageId === id) {
|
|
1499
|
+
this.pendingNavigation = void 0;
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
skipStage(id) {
|
|
1503
|
+
const stage = this.session.stages.find((s) => s.id === id);
|
|
1504
|
+
if (!stage) return;
|
|
1505
|
+
stage.status = "skipped";
|
|
1506
|
+
this.session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1507
|
+
}
|
|
1508
|
+
navigateTo(id) {
|
|
1509
|
+
const stage = this.session.stages.find((s) => s.id === id);
|
|
1510
|
+
if (!stage) return;
|
|
1511
|
+
if (stage.status === "complete") {
|
|
1512
|
+
if (id === "project_info") {
|
|
1513
|
+
this.pendingNavigation = {
|
|
1514
|
+
stageId: id,
|
|
1515
|
+
oldValue: null,
|
|
1516
|
+
oldProjectInfo: {
|
|
1517
|
+
projectName: this.session.progress.projectName,
|
|
1518
|
+
description: this.session.progress.description
|
|
1519
|
+
},
|
|
1520
|
+
oldSummary: stage.summary
|
|
1521
|
+
};
|
|
1522
|
+
} else {
|
|
1523
|
+
const key = stage.progressKeys[0];
|
|
1524
|
+
const oldValue = key === "extras" ? null : this.session.progress[key] ?? null;
|
|
1525
|
+
this.pendingNavigation = {
|
|
1526
|
+
stageId: id,
|
|
1527
|
+
oldValue,
|
|
1528
|
+
oldSummary: stage.summary
|
|
1529
|
+
};
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
stage.status = "pending";
|
|
1533
|
+
stage.summary = void 0;
|
|
1534
|
+
}
|
|
1535
|
+
restorePendingNavigation() {
|
|
1536
|
+
if (!this.pendingNavigation) return;
|
|
1537
|
+
const { stageId, oldValue, oldProjectInfo, oldSummary } = this.pendingNavigation;
|
|
1538
|
+
const stage = this.session.stages.find((s) => s.id === stageId);
|
|
1539
|
+
if (!stage) return;
|
|
1540
|
+
if (stageId === "project_info" && oldProjectInfo) {
|
|
1541
|
+
this.session.progress = {
|
|
1542
|
+
...this.session.progress,
|
|
1543
|
+
projectName: oldProjectInfo.projectName,
|
|
1544
|
+
description: oldProjectInfo.description
|
|
1545
|
+
};
|
|
1546
|
+
} else if (stageId !== "project_info") {
|
|
1547
|
+
const category = stageId;
|
|
1548
|
+
if (oldValue) {
|
|
1549
|
+
this.session.progress = { ...this.session.progress, [category]: oldValue };
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
stage.status = "complete";
|
|
1553
|
+
stage.summary = oldSummary;
|
|
1554
|
+
this.pendingNavigation = void 0;
|
|
1555
|
+
}
|
|
1556
|
+
isNavigating() {
|
|
1557
|
+
return this.pendingNavigation !== void 0;
|
|
1558
|
+
}
|
|
1559
|
+
getPendingOldValue() {
|
|
1560
|
+
return this.pendingNavigation?.oldValue ?? null;
|
|
1561
|
+
}
|
|
1562
|
+
// --- Dynamic stages ---
|
|
1563
|
+
addStage(entry, afterId) {
|
|
1564
|
+
const idx = this.session.stages.findIndex((s) => s.id === afterId);
|
|
1565
|
+
if (idx === -1) {
|
|
1566
|
+
this.session.stages.push(entry);
|
|
1567
|
+
} else {
|
|
1568
|
+
this.session.stages.splice(idx + 1, 0, entry);
|
|
1569
|
+
}
|
|
1570
|
+
this.session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1571
|
+
}
|
|
1572
|
+
removeStage(id) {
|
|
1573
|
+
this.session.stages = this.session.stages.filter((s) => s.id !== id);
|
|
1574
|
+
this.session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1575
|
+
}
|
|
1576
|
+
// --- Cascading invalidation ---
|
|
1577
|
+
async invalidateAfter(changedId, oldValue) {
|
|
1578
|
+
if (!this.onInvalidate) return;
|
|
1579
|
+
const stage = this.session.stages.find((s) => s.id === changedId);
|
|
1580
|
+
if (!stage) return;
|
|
1581
|
+
const key = stage.progressKeys[0];
|
|
1582
|
+
const newValue = key === "extras" || key === "projectName" || key === "description" ? null : this.session.progress[key] ?? null;
|
|
1583
|
+
const result = await this.onInvalidate(
|
|
1584
|
+
changedId,
|
|
1585
|
+
oldValue,
|
|
1586
|
+
newValue,
|
|
1587
|
+
this.session.progress,
|
|
1588
|
+
this.session.stages
|
|
1589
|
+
);
|
|
1590
|
+
const changedIdx = this.session.stages.findIndex((s) => s.id === changedId);
|
|
1591
|
+
for (const clearId of result.clear) {
|
|
1592
|
+
const clearIdx = this.session.stages.findIndex((s) => s.id === clearId);
|
|
1593
|
+
if (clearIdx <= changedIdx) continue;
|
|
1594
|
+
const clearStage = this.session.stages[clearIdx];
|
|
1595
|
+
if (!clearStage) continue;
|
|
1596
|
+
for (const pKey of clearStage.progressKeys) {
|
|
1597
|
+
if (pKey === "projectName" || pKey === "description") {
|
|
1598
|
+
this.session.progress = clearProjectInfo(this.session.progress);
|
|
1599
|
+
} else if (pKey === "extras") {
|
|
1600
|
+
this.session.progress = clearDecision(this.session.progress, "extras");
|
|
1601
|
+
} else {
|
|
1602
|
+
this.session.progress = clearDecision(this.session.progress, pKey);
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
clearStage.status = "pending";
|
|
1606
|
+
clearStage.summary = void 0;
|
|
1607
|
+
}
|
|
1608
|
+
for (const removeId of result.remove) {
|
|
1609
|
+
const removeIdx = this.session.stages.findIndex((s) => s.id === removeId);
|
|
1610
|
+
if (removeIdx <= changedIdx) continue;
|
|
1611
|
+
this.removeStage(removeId);
|
|
1612
|
+
}
|
|
1613
|
+
let insertAfterId = changedId;
|
|
1614
|
+
for (const newStage of result.add) {
|
|
1615
|
+
this.addStage(newStage, insertAfterId);
|
|
1616
|
+
insertAfterId = newStage.id;
|
|
1617
|
+
}
|
|
1618
|
+
this.session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1619
|
+
}
|
|
1620
|
+
// --- Persistence ---
|
|
1621
|
+
save() {
|
|
1622
|
+
this.session.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1623
|
+
const json = serializeSession(this.session);
|
|
1624
|
+
if (json.length > 5e5) {
|
|
1625
|
+
console.warn("Warning: session file exceeds 500KB. Consider summarizing more aggressively.");
|
|
1626
|
+
}
|
|
1627
|
+
try {
|
|
1628
|
+
const tmpPath = this.filePath + ".tmp";
|
|
1629
|
+
writeFileSync2(tmpPath, json, "utf-8");
|
|
1630
|
+
renameSync(tmpPath, this.filePath);
|
|
1631
|
+
} catch (err) {
|
|
1632
|
+
console.warn(`Warning: failed to save session: ${err instanceof Error ? err.message : String(err)}`);
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
cleanup() {
|
|
1636
|
+
try {
|
|
1637
|
+
if (existsSync3(this.filePath)) unlinkSync(this.filePath);
|
|
1638
|
+
const tmpPath = this.filePath + ".tmp";
|
|
1639
|
+
if (existsSync3(tmpPath)) unlinkSync(tmpPath);
|
|
1640
|
+
} catch {
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
// --- Accessors ---
|
|
1644
|
+
get progress() {
|
|
1645
|
+
return this.session.progress;
|
|
1646
|
+
}
|
|
1647
|
+
set progress(value) {
|
|
1648
|
+
this.session.progress = value;
|
|
1649
|
+
}
|
|
1650
|
+
get messages() {
|
|
1651
|
+
return this.session.messages;
|
|
1652
|
+
}
|
|
1653
|
+
set messages(value) {
|
|
1654
|
+
this.session.messages = value;
|
|
1655
|
+
}
|
|
1656
|
+
get stages() {
|
|
1657
|
+
return this.session.stages;
|
|
1658
|
+
}
|
|
1659
|
+
get updatedAt() {
|
|
1660
|
+
return this.session.updatedAt;
|
|
1661
|
+
}
|
|
1662
|
+
};
|
|
1663
|
+
|
|
1664
|
+
// src/cli/chat.ts
|
|
1665
|
+
import { Marked } from "marked";
|
|
1666
|
+
import { markedTerminal } from "marked-terminal";
|
|
1667
|
+
var marked = new Marked(markedTerminal());
|
|
1668
|
+
function renderPostScaffold(projectName, readiness) {
|
|
1669
|
+
const localSteps = [
|
|
1670
|
+
`cd ${projectName}`,
|
|
1671
|
+
"cp .env.example .env # fill in your values",
|
|
1672
|
+
"npm install",
|
|
1673
|
+
"npm run dev"
|
|
1674
|
+
];
|
|
1675
|
+
console.log("Local Development\n " + localSteps.join("\n "));
|
|
1676
|
+
if (readiness === null) return;
|
|
1677
|
+
const lines = [];
|
|
1678
|
+
if (readiness.cliInstalled && readiness.authenticated === true) {
|
|
1679
|
+
lines.push("\u2713 Ready to deploy");
|
|
1680
|
+
lines.push(`\u2192 ${readiness.deployCmd}`);
|
|
1681
|
+
} else if (!readiness.cliInstalled) {
|
|
1682
|
+
lines.push(`\u2717 ${readiness.cliName || "CLI"} not found`);
|
|
1683
|
+
if (readiness.installCmd) lines.push(` Install: ${readiness.installCmd}`);
|
|
1684
|
+
if (readiness.authCmd) lines.push(` Then: ${readiness.authCmd}`);
|
|
1685
|
+
lines.push(` Then: ${readiness.deployCmd}`);
|
|
1686
|
+
} else {
|
|
1687
|
+
lines.push(`\u2713 ${readiness.cliName} CLI installed`);
|
|
1688
|
+
if (readiness.authenticated === false) {
|
|
1689
|
+
lines.push(`\u2717 Not authenticated`);
|
|
1690
|
+
lines.push(` Run: ${readiness.authCmd}`);
|
|
1691
|
+
} else {
|
|
1692
|
+
lines.push("? Authentication status unknown");
|
|
1693
|
+
lines.push(` Try: ${readiness.authCmd}`);
|
|
1694
|
+
}
|
|
1695
|
+
lines.push(` Then: ${readiness.deployCmd}`);
|
|
1696
|
+
}
|
|
1697
|
+
lines.push("");
|
|
1698
|
+
if (readiness.envVarCmd) {
|
|
1699
|
+
lines.push(`\u2139 Set production env vars with: ${readiness.envVarCmd}`);
|
|
1700
|
+
}
|
|
1701
|
+
lines.push("\u2139 See README.md \u2192 Deployment for full instructions");
|
|
1702
|
+
console.log(`Deployment (${readiness.platform})
|
|
1703
|
+
${lines.join("\n ")}`);
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
// src/deploy/readiness.ts
|
|
1707
|
+
import { execFileSync as execFileSync2 } from "child_process";
|
|
1708
|
+
var PLATFORMS = [
|
|
1709
|
+
{
|
|
1710
|
+
platform: "AWS Amplify",
|
|
1711
|
+
cliName: "amplify",
|
|
1712
|
+
cliBinary: "amplify",
|
|
1713
|
+
authCheckCmd: ["amplify", "status"],
|
|
1714
|
+
installCmd: "npm i -g @aws-amplify/cli",
|
|
1715
|
+
authCmd: "amplify configure",
|
|
1716
|
+
deployCmd: "npm run deploy",
|
|
1717
|
+
envVarCmd: "See AWS Amplify console for environment variables"
|
|
1718
|
+
},
|
|
1719
|
+
{
|
|
1720
|
+
platform: "AWS CDK",
|
|
1721
|
+
cliName: "cdk",
|
|
1722
|
+
cliBinary: "cdk",
|
|
1723
|
+
authCheckCmd: ["aws", "sts", "get-caller-identity"],
|
|
1724
|
+
installCmd: "npm i -g aws-cdk",
|
|
1725
|
+
authCmd: "aws configure",
|
|
1726
|
+
deployCmd: "npm run deploy",
|
|
1727
|
+
envVarCmd: "aws ssm put-parameter --name KEY --value VAL --type String"
|
|
1728
|
+
},
|
|
1729
|
+
{
|
|
1730
|
+
platform: "AWS SST",
|
|
1731
|
+
cliName: "sst",
|
|
1732
|
+
cliBinary: "npx",
|
|
1733
|
+
authCheckCmd: ["aws", "sts", "get-caller-identity"],
|
|
1734
|
+
installCmd: "(uses npx \u2014 no global install needed)",
|
|
1735
|
+
authCmd: "aws configure",
|
|
1736
|
+
deployCmd: "npm run deploy",
|
|
1737
|
+
envVarCmd: "aws ssm put-parameter --name KEY --value VAL --type String"
|
|
1738
|
+
},
|
|
1739
|
+
{
|
|
1740
|
+
platform: "Vercel",
|
|
1741
|
+
cliName: "vercel",
|
|
1742
|
+
cliBinary: "vercel",
|
|
1743
|
+
authCheckCmd: ["vercel", "whoami"],
|
|
1744
|
+
installCmd: "npm i -g vercel",
|
|
1745
|
+
authCmd: "vercel login",
|
|
1746
|
+
deployCmd: "npm run deploy",
|
|
1747
|
+
envVarCmd: "vercel env add"
|
|
1748
|
+
},
|
|
1749
|
+
{
|
|
1750
|
+
platform: "AWS",
|
|
1751
|
+
cliName: "aws",
|
|
1752
|
+
cliBinary: "aws",
|
|
1753
|
+
authCheckCmd: ["aws", "sts", "get-caller-identity"],
|
|
1754
|
+
installCmd: "See https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html",
|
|
1755
|
+
authCmd: "aws configure",
|
|
1756
|
+
deployCmd: "npm run deploy",
|
|
1757
|
+
envVarCmd: "aws ssm put-parameter --name KEY --value VAL --type String"
|
|
1758
|
+
},
|
|
1759
|
+
{
|
|
1760
|
+
platform: "GCP",
|
|
1761
|
+
cliName: "gcloud",
|
|
1762
|
+
cliBinary: "gcloud",
|
|
1763
|
+
authCheckCmd: ["gcloud", "auth", "print-identity-token"],
|
|
1764
|
+
installCmd: "See https://cloud.google.com/sdk/docs/install",
|
|
1765
|
+
authCmd: "gcloud auth login",
|
|
1766
|
+
deployCmd: "npm run deploy",
|
|
1767
|
+
envVarCmd: "gcloud run services update SERVICE --set-env-vars KEY=VAL"
|
|
1768
|
+
},
|
|
1769
|
+
{
|
|
1770
|
+
platform: "Docker",
|
|
1771
|
+
cliName: "docker",
|
|
1772
|
+
cliBinary: "docker",
|
|
1773
|
+
authCheckCmd: ["docker", "info"],
|
|
1774
|
+
installCmd: "See https://docs.docker.com/get-docker/",
|
|
1775
|
+
authCmd: "docker login",
|
|
1776
|
+
deployCmd: "npm run deploy",
|
|
1777
|
+
envVarCmd: "Set variables in docker-compose.yml or .env"
|
|
1778
|
+
},
|
|
1779
|
+
{
|
|
1780
|
+
platform: "Railway",
|
|
1781
|
+
cliName: "railway",
|
|
1782
|
+
cliBinary: "railway",
|
|
1783
|
+
authCheckCmd: ["railway", "whoami"],
|
|
1784
|
+
installCmd: "npm i -g @railway/cli",
|
|
1785
|
+
authCmd: "railway login",
|
|
1786
|
+
deployCmd: "npm run deploy",
|
|
1787
|
+
envVarCmd: "railway variables set KEY=VAL"
|
|
1788
|
+
},
|
|
1789
|
+
{
|
|
1790
|
+
platform: "Fly.io",
|
|
1791
|
+
cliName: "fly",
|
|
1792
|
+
cliBinary: "fly",
|
|
1793
|
+
authCheckCmd: ["fly", "auth", "whoami"],
|
|
1794
|
+
installCmd: "See https://fly.io/docs/flyctl/install/",
|
|
1795
|
+
authCmd: "fly auth login",
|
|
1796
|
+
deployCmd: "npm run deploy",
|
|
1797
|
+
envVarCmd: "fly secrets set KEY=VAL"
|
|
1798
|
+
}
|
|
1799
|
+
];
|
|
1800
|
+
var KEYWORD_MATCHERS = (() => {
|
|
1801
|
+
const byPlatform = (name) => PLATFORMS.find((p) => p.platform === name);
|
|
1802
|
+
return [
|
|
1803
|
+
{ test: (s) => s.includes("amplify"), platform: byPlatform("AWS Amplify") },
|
|
1804
|
+
{ test: (s) => s.includes("cdk"), platform: byPlatform("AWS CDK") },
|
|
1805
|
+
{ test: (s) => s.includes("sst"), platform: byPlatform("AWS SST") },
|
|
1806
|
+
{ test: (s) => s.includes("vercel"), platform: byPlatform("Vercel") },
|
|
1807
|
+
{ test: (s) => s.includes("aws") || s.includes("lambda") || s.includes("ec2"), platform: byPlatform("AWS") },
|
|
1808
|
+
{ test: (s) => s.includes("gcp") || s.includes("google cloud") || s.includes("cloud run"), platform: byPlatform("GCP") },
|
|
1809
|
+
{ test: (s) => s.includes("docker") || s.includes("container"), platform: byPlatform("Docker") },
|
|
1810
|
+
{ test: (s) => s.includes("railway"), platform: byPlatform("Railway") },
|
|
1811
|
+
{ test: (s) => s.includes("fly.io") || s === "fly" || s.startsWith("fly "), platform: byPlatform("Fly.io") }
|
|
1812
|
+
];
|
|
1813
|
+
})();
|
|
1814
|
+
var FALLBACK_CONFIG = {
|
|
1815
|
+
platform: "Unknown",
|
|
1816
|
+
cliName: "",
|
|
1817
|
+
cliBinary: "",
|
|
1818
|
+
authCheckCmd: [],
|
|
1819
|
+
installCmd: "",
|
|
1820
|
+
authCmd: "",
|
|
1821
|
+
deployCmd: "npm run deploy",
|
|
1822
|
+
envVarCmd: "See README.md for environment variable instructions"
|
|
1823
|
+
};
|
|
1824
|
+
function normalizePlatform(deploymentComponent) {
|
|
1825
|
+
const lower = deploymentComponent.toLowerCase();
|
|
1826
|
+
for (const matcher of KEYWORD_MATCHERS) {
|
|
1827
|
+
if (matcher.test(lower)) {
|
|
1828
|
+
return matcher.platform;
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
return FALLBACK_CONFIG;
|
|
1832
|
+
}
|
|
1833
|
+
function isCliInstalled(binary) {
|
|
1834
|
+
try {
|
|
1835
|
+
execFileSync2("which", [binary], { stdio: "pipe", timeout: 5e3 });
|
|
1836
|
+
return true;
|
|
1837
|
+
} catch {
|
|
1838
|
+
return false;
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
function checkAuth(cmd) {
|
|
1842
|
+
if (cmd.length === 0) return null;
|
|
1843
|
+
try {
|
|
1844
|
+
execFileSync2(cmd[0], cmd.slice(1), { stdio: "pipe", timeout: 5e3 });
|
|
1845
|
+
return true;
|
|
1846
|
+
} catch (err) {
|
|
1847
|
+
const nodeErr = err;
|
|
1848
|
+
if (nodeErr.code === "ENOENT" || nodeErr.code === "ETIMEDOUT") {
|
|
1849
|
+
return null;
|
|
1850
|
+
}
|
|
1851
|
+
return false;
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
function checkDeployReadiness(deploymentComponent) {
|
|
1855
|
+
const config = normalizePlatform(deploymentComponent);
|
|
1856
|
+
if (config.platform === "Unknown" || config.cliBinary === "") {
|
|
1857
|
+
return {
|
|
1858
|
+
platform: config.platform,
|
|
1859
|
+
cliInstalled: false,
|
|
1860
|
+
cliName: config.cliName,
|
|
1861
|
+
authenticated: null,
|
|
1862
|
+
installCmd: config.installCmd,
|
|
1863
|
+
authCmd: config.authCmd,
|
|
1864
|
+
deployCmd: config.deployCmd,
|
|
1865
|
+
envVarCmd: config.envVarCmd
|
|
1866
|
+
};
|
|
1867
|
+
}
|
|
1868
|
+
const cliInstalled = isCliInstalled(config.cliBinary);
|
|
1869
|
+
let authenticated = null;
|
|
1870
|
+
if (cliInstalled) {
|
|
1871
|
+
authenticated = checkAuth(config.authCheckCmd);
|
|
1872
|
+
}
|
|
1873
|
+
return {
|
|
1874
|
+
platform: config.platform,
|
|
1875
|
+
cliInstalled,
|
|
1876
|
+
cliName: config.cliName,
|
|
1877
|
+
authenticated,
|
|
1878
|
+
installCmd: config.installCmd,
|
|
1879
|
+
authCmd: config.authCmd,
|
|
1880
|
+
deployCmd: config.deployCmd,
|
|
1881
|
+
envVarCmd: config.envVarCmd
|
|
1882
|
+
};
|
|
726
1883
|
}
|
|
727
1884
|
|
|
728
1885
|
// src/index.ts
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
1886
|
+
var INVALIDATION_PROMPT = `You are evaluating whether changing a technology stack decision affects other decisions.
|
|
1887
|
+
|
|
1888
|
+
The user changed their decision. Given the current state of all decisions, determine which OTHER decisions (if any) are now invalid and should be reconsidered.
|
|
1889
|
+
|
|
1890
|
+
Rules:
|
|
1891
|
+
- Only include stages that are GENUINELY affected by the change.
|
|
1892
|
+
- Only affect stages AFTER the changed stage in the ordered list.
|
|
1893
|
+
- Consider whether each decision was dependent on the changed decision.
|
|
1894
|
+
- If nothing needs to change, return empty arrays.
|
|
1895
|
+
|
|
1896
|
+
Examples:
|
|
1897
|
+
|
|
1898
|
+
Changed frontend from Next.js to Astro (backend was "Next.js API routes"):
|
|
1899
|
+
{"clear":["backend"],"add":[],"remove":[]}
|
|
1900
|
+
Reason: Backend was tied to Next.js. If backend had been "Express" (independent), it would NOT be cleared.
|
|
1901
|
+
|
|
1902
|
+
Changed auth from Clerk to Auth.js:
|
|
1903
|
+
{"clear":[],"add":[],"remove":[]}
|
|
1904
|
+
Reason: Swapping auth providers doesn't affect other decisions.
|
|
1905
|
+
|
|
1906
|
+
Changed frontend from Next.js to static HTML:
|
|
1907
|
+
{"clear":["backend","auth","ai"],"add":[],"remove":["payments"]}
|
|
1908
|
+
Reason: Static site fundamentally changes what's viable.
|
|
1909
|
+
|
|
1910
|
+
Respond with ONLY a JSON object: {"clear": [...], "add": [...], "remove": [...]}
|
|
1911
|
+
`;
|
|
1912
|
+
function createInvalidationFn() {
|
|
1913
|
+
return async (changedId, oldValue, newValue, progress, stages) => {
|
|
1914
|
+
const stageList = stages.map((s) => `${s.id} (${s.status}): ${s.summary ?? "no decision"}`).join("\n");
|
|
1915
|
+
const userPrompt = `The user changed "${changedId}" from "${oldValue?.component ?? "none"}" to "${newValue?.component ?? "none"}".
|
|
1916
|
+
|
|
1917
|
+
Current decisions:
|
|
1918
|
+
${serializeProgress(progress)}
|
|
1919
|
+
|
|
1920
|
+
Current stages:
|
|
1921
|
+
${stageList}
|
|
1922
|
+
|
|
1923
|
+
What needs to change?`;
|
|
1924
|
+
try {
|
|
1925
|
+
const response = await chat({
|
|
1926
|
+
system: INVALIDATION_PROMPT,
|
|
1927
|
+
messages: [{ role: "user", content: userPrompt }],
|
|
1928
|
+
maxTokens: 1024
|
|
1929
|
+
});
|
|
1930
|
+
const text = response.content.filter((b) => b.type === "text").map((b) => b.text).join("");
|
|
1931
|
+
const parsed = JSON.parse(text);
|
|
1932
|
+
return {
|
|
1933
|
+
clear: Array.isArray(parsed.clear) ? parsed.clear : [],
|
|
1934
|
+
add: Array.isArray(parsed.add) ? parsed.add : [],
|
|
1935
|
+
remove: Array.isArray(parsed.remove) ? parsed.remove : []
|
|
1936
|
+
};
|
|
1937
|
+
} catch {
|
|
1938
|
+
return { clear: [], add: [], remove: [] };
|
|
1939
|
+
}
|
|
1940
|
+
};
|
|
1941
|
+
}
|
|
1942
|
+
async function main(fresh = false) {
|
|
1943
|
+
const cwd = process.cwd();
|
|
1944
|
+
const invalidationFn = createInvalidationFn();
|
|
1945
|
+
process.on("SIGINT", () => {
|
|
1946
|
+
process.exit(0);
|
|
738
1947
|
});
|
|
739
|
-
if (
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
1948
|
+
if (fresh) {
|
|
1949
|
+
const tempManager = StageManager.resume(cwd);
|
|
1950
|
+
tempManager?.cleanup();
|
|
1951
|
+
console.log("Session cleared. Starting fresh.\n");
|
|
1952
|
+
}
|
|
1953
|
+
let manager;
|
|
1954
|
+
const existingSession = fresh ? null : StageManager.detect(cwd);
|
|
1955
|
+
if (existingSession) {
|
|
1956
|
+
console.log(`
|
|
1957
|
+
Found saved progress for "${existingSession.progress.projectName ?? "unnamed"}"`);
|
|
1958
|
+
console.log("Run with --fresh to start over.\n");
|
|
1959
|
+
const resumed = StageManager.resume(cwd, invalidationFn);
|
|
1960
|
+
if (!resumed) {
|
|
1961
|
+
console.log("Could not restore session. Starting fresh.");
|
|
1962
|
+
manager = StageManager.start(cwd, invalidationFn);
|
|
1963
|
+
} else {
|
|
1964
|
+
manager = resumed;
|
|
1965
|
+
}
|
|
750
1966
|
} else {
|
|
751
|
-
|
|
752
|
-
|
|
1967
|
+
manager = StageManager.start(cwd, invalidationFn);
|
|
1968
|
+
}
|
|
1969
|
+
let shouldBuild = false;
|
|
1970
|
+
const ink = withFullScreen(
|
|
1971
|
+
React6.createElement(App, {
|
|
1972
|
+
manager,
|
|
1973
|
+
onBuild: () => {
|
|
1974
|
+
shouldBuild = true;
|
|
1975
|
+
},
|
|
1976
|
+
onExit: () => {
|
|
1977
|
+
shouldBuild = false;
|
|
1978
|
+
}
|
|
1979
|
+
})
|
|
1980
|
+
);
|
|
1981
|
+
await ink.start();
|
|
1982
|
+
await ink.waitUntilExit();
|
|
1983
|
+
if (shouldBuild) {
|
|
1984
|
+
console.log("\nScaffolding your project...\n");
|
|
1985
|
+
const success = await runScaffoldLoop(manager.progress);
|
|
1986
|
+
if (success) {
|
|
1987
|
+
const readiness = manager.progress.deployment ? checkDeployReadiness(manager.progress.deployment.component) : null;
|
|
1988
|
+
renderPostScaffold(manager.progress.projectName, readiness);
|
|
1989
|
+
manager.cleanup();
|
|
1990
|
+
console.log("\nHappy building!\n");
|
|
1991
|
+
} else {
|
|
1992
|
+
console.error("\nScaffolding encountered errors. Check the output above.\n");
|
|
1993
|
+
}
|
|
753
1994
|
}
|
|
754
1995
|
}
|
|
755
|
-
var
|
|
756
|
-
|
|
757
|
-
|
|
1996
|
+
var args = process.argv.slice(2);
|
|
1997
|
+
var command = args[0];
|
|
1998
|
+
var isFresh = args.includes("--fresh");
|
|
1999
|
+
if (!command || command === "init" || command === "--fresh") {
|
|
2000
|
+
main(isFresh).catch((err) => {
|
|
758
2001
|
console.error(err);
|
|
759
2002
|
process.exit(1);
|
|
760
2003
|
});
|
|
761
2004
|
} else {
|
|
762
2005
|
console.error(`Unknown command: ${command}`);
|
|
763
|
-
console.error("Usage: stack-agent [init]");
|
|
2006
|
+
console.error("Usage: stack-agent [init] [--fresh]");
|
|
764
2007
|
process.exit(1);
|
|
765
2008
|
}
|