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