stack-agent 0.1.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/LICENSE +21 -0
- package/README.md +44 -0
- package/dist/index.js +765 -0
- package/package.json +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 stack-agent contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# stack-agent
|
|
2
|
+
|
|
3
|
+
AI-powered CLI that helps developers choose and scaffold full-stack applications through conversational interaction.
|
|
4
|
+
|
|
5
|
+
A senior software architect in your terminal — it walks you through stack decisions, explains trade-offs, and scaffolds your project using official framework tools.
|
|
6
|
+
|
|
7
|
+
## How it works
|
|
8
|
+
|
|
9
|
+
1. **Conversation** — The agent asks what you're building, then guides you through frontend, backend, database, auth, payments, AI/LLM, and deployment choices
|
|
10
|
+
2. **Recommendations** — Each stage presents 2-3 options with a recommended pick and trade-off context
|
|
11
|
+
3. **Review** — Once all decisions are made, the agent presents your full stack for approval
|
|
12
|
+
4. **Scaffold** — The agent runs official tools (create-next-app, create-vite, etc.) and generates integration code grounded by current documentation
|
|
13
|
+
|
|
14
|
+
## Quick start
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
export ANTHROPIC_API_KEY=your-key-here
|
|
18
|
+
npx stack-agent
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Requirements
|
|
22
|
+
|
|
23
|
+
- Node.js 20+
|
|
24
|
+
- An [Anthropic API key](https://console.anthropic.com/settings/keys)
|
|
25
|
+
|
|
26
|
+
## What it does
|
|
27
|
+
|
|
28
|
+
- Delegates base scaffolding to official framework CLIs (create-next-app, create-vite, etc.)
|
|
29
|
+
- Generates integration code (auth, database, payments) using Claude, grounded by up-to-date documentation via MCP
|
|
30
|
+
- Writes `.env.example` with required environment variables
|
|
31
|
+
- Installs dependencies automatically
|
|
32
|
+
|
|
33
|
+
## Development
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm install
|
|
37
|
+
npm run dev # Run with tsx
|
|
38
|
+
npm test # Run tests
|
|
39
|
+
npm run build # Build with tsup
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## License
|
|
43
|
+
|
|
44
|
+
MIT
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,765 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import * as p2 from "@clack/prompts";
|
|
5
|
+
|
|
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();
|
|
13
|
+
}
|
|
14
|
+
function intro2() {
|
|
15
|
+
p.intro("stack-agent");
|
|
16
|
+
}
|
|
17
|
+
function outro2(message) {
|
|
18
|
+
p.outro(message);
|
|
19
|
+
}
|
|
20
|
+
function renderError(text2) {
|
|
21
|
+
p.log.error(text2);
|
|
22
|
+
}
|
|
23
|
+
function renderPlan(plan) {
|
|
24
|
+
p.log.info(renderMarkdown(plan));
|
|
25
|
+
}
|
|
26
|
+
async function getUserInput(message, placeholder) {
|
|
27
|
+
const result = await p.text({
|
|
28
|
+
message: message ?? "\u203A",
|
|
29
|
+
placeholder: placeholder ?? "Type your message..."
|
|
30
|
+
});
|
|
31
|
+
if (p.isCancel(result)) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
function createSpinner() {
|
|
37
|
+
return p.spinner();
|
|
38
|
+
}
|
|
39
|
+
function writeText(text2) {
|
|
40
|
+
process.stdout.write(text2);
|
|
41
|
+
}
|
|
42
|
+
function writeLine() {
|
|
43
|
+
process.stdout.write("\n");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// src/agent/loop.ts
|
|
47
|
+
import { join as join2 } from "path";
|
|
48
|
+
|
|
49
|
+
// src/llm/client.ts
|
|
50
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
51
|
+
function getClient() {
|
|
52
|
+
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
53
|
+
if (!apiKey) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
"ANTHROPIC_API_KEY environment variable is not set. Please set it before running stack-agent."
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return new Anthropic({ apiKey });
|
|
59
|
+
}
|
|
60
|
+
var _client = null;
|
|
61
|
+
function client() {
|
|
62
|
+
if (!_client) {
|
|
63
|
+
_client = getClient();
|
|
64
|
+
}
|
|
65
|
+
return _client;
|
|
66
|
+
}
|
|
67
|
+
async function chat(options) {
|
|
68
|
+
const { system, messages, tools, maxTokens, mcpServers } = options;
|
|
69
|
+
if (mcpServers && Object.keys(mcpServers).length > 0) {
|
|
70
|
+
const mcpServerList = Object.entries(mcpServers).map(([name, config]) => ({
|
|
71
|
+
type: "url",
|
|
72
|
+
name,
|
|
73
|
+
url: config.url,
|
|
74
|
+
...config.apiKey !== void 0 && {
|
|
75
|
+
authorization_token: config.apiKey
|
|
76
|
+
}
|
|
77
|
+
}));
|
|
78
|
+
return client().beta.messages.create(
|
|
79
|
+
{
|
|
80
|
+
model: "claude-sonnet-4-6",
|
|
81
|
+
max_tokens: maxTokens,
|
|
82
|
+
system,
|
|
83
|
+
messages,
|
|
84
|
+
tools,
|
|
85
|
+
mcp_servers: mcpServerList
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
headers: {
|
|
89
|
+
"anthropic-beta": "mcp-client-2025-11-20"
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return client().messages.create({
|
|
95
|
+
model: "claude-sonnet-4-6",
|
|
96
|
+
max_tokens: maxTokens,
|
|
97
|
+
system,
|
|
98
|
+
messages,
|
|
99
|
+
tools
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
async function chatStream(options, callbacks) {
|
|
103
|
+
const { system, messages, tools, maxTokens } = options;
|
|
104
|
+
const stream = client().messages.stream({
|
|
105
|
+
model: "claude-sonnet-4-6",
|
|
106
|
+
max_tokens: maxTokens,
|
|
107
|
+
system,
|
|
108
|
+
messages,
|
|
109
|
+
tools
|
|
110
|
+
});
|
|
111
|
+
stream.on("text", (text2) => {
|
|
112
|
+
callbacks.onText(text2);
|
|
113
|
+
});
|
|
114
|
+
const finalMessage = await stream.finalMessage();
|
|
115
|
+
for (const block of finalMessage.content) {
|
|
116
|
+
if (block.type === "tool_use") {
|
|
117
|
+
callbacks.onToolUse(block);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
callbacks.onComplete({
|
|
121
|
+
content: finalMessage.content,
|
|
122
|
+
stop_reason: finalMessage.stop_reason ?? "end_turn"
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
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
|
+
// src/agent/tools.ts
|
|
168
|
+
function conversationToolDefinitions() {
|
|
169
|
+
return [
|
|
170
|
+
{
|
|
171
|
+
name: "set_decision",
|
|
172
|
+
description: "Commits a stack decision for a given category.",
|
|
173
|
+
input_schema: {
|
|
174
|
+
type: "object",
|
|
175
|
+
properties: {
|
|
176
|
+
category: {
|
|
177
|
+
type: "string",
|
|
178
|
+
enum: ["frontend", "backend", "database", "auth", "payments", "ai", "deployment", "extras"],
|
|
179
|
+
description: "The stack category being decided."
|
|
180
|
+
},
|
|
181
|
+
component: {
|
|
182
|
+
type: "string",
|
|
183
|
+
description: "The name of the chosen component or technology."
|
|
184
|
+
},
|
|
185
|
+
reasoning: {
|
|
186
|
+
type: "string",
|
|
187
|
+
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
|
+
}
|
|
198
|
+
},
|
|
199
|
+
required: ["category", "component", "reasoning"]
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
name: "set_project_info",
|
|
204
|
+
description: "Sets the project name and description.",
|
|
205
|
+
input_schema: {
|
|
206
|
+
type: "object",
|
|
207
|
+
properties: {
|
|
208
|
+
projectName: {
|
|
209
|
+
type: "string",
|
|
210
|
+
description: "The name of the project."
|
|
211
|
+
},
|
|
212
|
+
description: {
|
|
213
|
+
type: "string",
|
|
214
|
+
description: "A short description of the project."
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
required: ["projectName", "description"]
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
name: "summarize_stage",
|
|
222
|
+
description: "Summarizes the conversation for a completed stage.",
|
|
223
|
+
input_schema: {
|
|
224
|
+
type: "object",
|
|
225
|
+
properties: {
|
|
226
|
+
category: {
|
|
227
|
+
type: "string",
|
|
228
|
+
description: "The stage/category that was just completed."
|
|
229
|
+
},
|
|
230
|
+
summary: {
|
|
231
|
+
type: "string",
|
|
232
|
+
description: "A concise summary of what was decided in this stage."
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
required: ["category", "summary"]
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
name: "present_plan",
|
|
240
|
+
description: "Signals that all decisions have been made and the plan is ready to present.",
|
|
241
|
+
input_schema: {
|
|
242
|
+
type: "object",
|
|
243
|
+
properties: {},
|
|
244
|
+
required: []
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
];
|
|
248
|
+
}
|
|
249
|
+
function scaffoldToolDefinitions() {
|
|
250
|
+
return [
|
|
251
|
+
{
|
|
252
|
+
name: "run_scaffold",
|
|
253
|
+
description: "Runs an official scaffold CLI to bootstrap a project.",
|
|
254
|
+
input_schema: {
|
|
255
|
+
type: "object",
|
|
256
|
+
properties: {
|
|
257
|
+
tool: {
|
|
258
|
+
type: "string",
|
|
259
|
+
description: "The scaffold CLI tool to run (e.g. create-next-app)."
|
|
260
|
+
},
|
|
261
|
+
args: {
|
|
262
|
+
type: "array",
|
|
263
|
+
items: { type: "string" },
|
|
264
|
+
description: "Arguments to pass to the scaffold tool."
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
required: ["tool", "args"]
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
name: "add_integration",
|
|
272
|
+
description: "Writes files, installs dependencies, and adds environment variables for an integration.",
|
|
273
|
+
input_schema: {
|
|
274
|
+
type: "object",
|
|
275
|
+
properties: {
|
|
276
|
+
files: {
|
|
277
|
+
type: "object",
|
|
278
|
+
additionalProperties: { type: "string" },
|
|
279
|
+
description: "Map of file paths to file contents to write."
|
|
280
|
+
},
|
|
281
|
+
dependencies: {
|
|
282
|
+
type: "object",
|
|
283
|
+
additionalProperties: { type: "string" },
|
|
284
|
+
description: "Map of package names to versions to install as runtime dependencies."
|
|
285
|
+
},
|
|
286
|
+
devDependencies: {
|
|
287
|
+
type: "object",
|
|
288
|
+
additionalProperties: { type: "string" },
|
|
289
|
+
description: "Map of package names to versions to install as dev dependencies."
|
|
290
|
+
},
|
|
291
|
+
envVars: {
|
|
292
|
+
type: "array",
|
|
293
|
+
items: { type: "string" },
|
|
294
|
+
description: "List of environment variable names required by the integration."
|
|
295
|
+
}
|
|
296
|
+
},
|
|
297
|
+
required: ["files"]
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
];
|
|
301
|
+
}
|
|
302
|
+
function executeConversationTool(name, input, progress, _messages) {
|
|
303
|
+
if (name === "set_decision") {
|
|
304
|
+
const category = input.category;
|
|
305
|
+
const choice = {
|
|
306
|
+
component: input.component,
|
|
307
|
+
reasoning: input.reasoning,
|
|
308
|
+
...input.scaffoldTool !== void 0 && { scaffoldTool: input.scaffoldTool },
|
|
309
|
+
...input.scaffoldArgs !== void 0 && { scaffoldArgs: input.scaffoldArgs }
|
|
310
|
+
};
|
|
311
|
+
const updatedProgress = setDecision(progress, category, choice);
|
|
312
|
+
return {
|
|
313
|
+
progress: updatedProgress,
|
|
314
|
+
response: `Decision recorded: ${choice.component} for ${category}.`
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
if (name === "set_project_info") {
|
|
318
|
+
const updatedProgress = {
|
|
319
|
+
...progress,
|
|
320
|
+
projectName: input.projectName,
|
|
321
|
+
description: input.description
|
|
322
|
+
};
|
|
323
|
+
return {
|
|
324
|
+
progress: updatedProgress,
|
|
325
|
+
response: `Project info set: "${input.projectName}".`
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
if (name === "summarize_stage") {
|
|
329
|
+
return {
|
|
330
|
+
progress,
|
|
331
|
+
response: input.summary
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
if (name === "present_plan") {
|
|
335
|
+
return {
|
|
336
|
+
progress,
|
|
337
|
+
response: "Plan is ready to present.",
|
|
338
|
+
signal: "present_plan"
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
return {
|
|
342
|
+
progress,
|
|
343
|
+
response: `Unknown tool: "${name}".`
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// src/agent/system-prompt.ts
|
|
348
|
+
function buildConversationPrompt(progress) {
|
|
349
|
+
return `You are a senior software architect helping a developer set up a new project.
|
|
350
|
+
|
|
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.
|
|
352
|
+
|
|
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.
|
|
361
|
+
|
|
362
|
+
Do not ask the user to confirm each tool call \u2014 just make the calls naturally as decisions are reached.
|
|
363
|
+
|
|
364
|
+
Current project state:
|
|
365
|
+
${serializeProgress(progress)}`;
|
|
366
|
+
}
|
|
367
|
+
function buildScaffoldPrompt(progress) {
|
|
368
|
+
return `You are scaffolding a new software project based on an approved plan.
|
|
369
|
+
|
|
370
|
+
Approved plan:
|
|
371
|
+
${serializeProgress(progress)}
|
|
372
|
+
|
|
373
|
+
Instructions:
|
|
374
|
+
1. Call \`run_scaffold\` first to bootstrap the project using the appropriate scaffold CLI tool (e.g. create-next-app, create-vite, etc.).
|
|
375
|
+
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
|
+
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
|
+
4. Generate complete, working code \u2014 no stubs, no placeholders, no TODO comments. Every file should be production-ready.
|
|
378
|
+
|
|
379
|
+
Do not ask for confirmation. Proceed through all steps automatically.`;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// src/scaffold/base.ts
|
|
383
|
+
import { execFileSync } from "child_process";
|
|
384
|
+
import { readdirSync, existsSync } from "fs";
|
|
385
|
+
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);
|
|
453
|
+
const outputDir = join(cwd, projectName);
|
|
454
|
+
if (existsSync(outputDir)) {
|
|
455
|
+
const entries = readdirSync(outputDir);
|
|
456
|
+
if (entries.length > 0) {
|
|
457
|
+
throw new Error(
|
|
458
|
+
`Output directory "${outputDir}" already exists and is not empty`
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
const spawnArgs = [`${tool}@latest`, projectName, ...args];
|
|
463
|
+
const opts = { cwd, stdio: "pipe" };
|
|
464
|
+
execFileSync("npx", spawnArgs, opts);
|
|
465
|
+
return outputDir;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// src/scaffold/integrate.ts
|
|
469
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync as existsSync2, appendFileSync } from "fs";
|
|
470
|
+
import { resolve, relative, dirname, isAbsolute } from "path";
|
|
471
|
+
function validateFilePaths(projectRoot, files) {
|
|
472
|
+
for (const filePath of Object.keys(files)) {
|
|
473
|
+
if (isAbsolute(filePath)) {
|
|
474
|
+
throw new Error(`File path must be relative, got: "${filePath}"`);
|
|
475
|
+
}
|
|
476
|
+
const resolved = resolve(projectRoot, filePath);
|
|
477
|
+
const rel = relative(projectRoot, resolved);
|
|
478
|
+
if (rel.startsWith("..")) {
|
|
479
|
+
throw new Error(`File path "${filePath}" resolves outside project root`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
function writeIntegration(projectDir, input) {
|
|
484
|
+
const { files, dependencies, devDependencies, envVars } = input;
|
|
485
|
+
validateFilePaths(projectDir, files);
|
|
486
|
+
for (const [filePath, content] of Object.entries(files)) {
|
|
487
|
+
const fullPath = resolve(projectDir, filePath);
|
|
488
|
+
mkdirSync(dirname(fullPath), { recursive: true });
|
|
489
|
+
writeFileSync(fullPath, content, "utf8");
|
|
490
|
+
}
|
|
491
|
+
if (dependencies !== void 0 || devDependencies !== void 0) {
|
|
492
|
+
const pkgPath = resolve(projectDir, "package.json");
|
|
493
|
+
let pkg = {};
|
|
494
|
+
if (existsSync2(pkgPath)) {
|
|
495
|
+
pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
496
|
+
}
|
|
497
|
+
if (dependencies !== void 0) {
|
|
498
|
+
pkg.dependencies = {
|
|
499
|
+
...pkg.dependencies,
|
|
500
|
+
...dependencies
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
if (devDependencies !== void 0) {
|
|
504
|
+
pkg.devDependencies = {
|
|
505
|
+
...pkg.devDependencies,
|
|
506
|
+
...devDependencies
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
|
|
510
|
+
}
|
|
511
|
+
if (envVars !== void 0 && envVars.length > 0) {
|
|
512
|
+
const envPath = resolve(projectDir, ".env.example");
|
|
513
|
+
const lines = envVars.map((v) => `${v}=`).join("\n") + "\n";
|
|
514
|
+
appendFileSync(envPath, lines, "utf8");
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// 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." });
|
|
523
|
+
while (true) {
|
|
524
|
+
const system = buildConversationPrompt(progress);
|
|
525
|
+
let contentBlocks = [];
|
|
526
|
+
const collectedToolUse = [];
|
|
527
|
+
let hasText = false;
|
|
528
|
+
await chatStream(
|
|
529
|
+
{
|
|
530
|
+
system,
|
|
531
|
+
messages,
|
|
532
|
+
tools: conversationToolDefinitions(),
|
|
533
|
+
maxTokens: 4096,
|
|
534
|
+
mcpServers
|
|
535
|
+
},
|
|
536
|
+
{
|
|
537
|
+
onText: (delta) => {
|
|
538
|
+
if (!hasText) {
|
|
539
|
+
hasText = true;
|
|
540
|
+
writeText("\n");
|
|
541
|
+
}
|
|
542
|
+
writeText(delta);
|
|
543
|
+
},
|
|
544
|
+
onToolUse: (block) => {
|
|
545
|
+
collectedToolUse.push(block);
|
|
546
|
+
},
|
|
547
|
+
onComplete: (response) => {
|
|
548
|
+
contentBlocks = response.content;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
);
|
|
552
|
+
if (hasText) {
|
|
553
|
+
writeLine();
|
|
554
|
+
writeLine();
|
|
555
|
+
}
|
|
556
|
+
const toolUseBlocks = collectedToolUse;
|
|
557
|
+
if (toolUseBlocks.length > 0) {
|
|
558
|
+
messages.push({ role: "assistant", content: contentBlocks });
|
|
559
|
+
const toolResults = [];
|
|
560
|
+
let hasPresentPlan = false;
|
|
561
|
+
let hasSummarizeStage = false;
|
|
562
|
+
let summarizeSummary = "";
|
|
563
|
+
for (const block of toolUseBlocks) {
|
|
564
|
+
const toolBlock = block;
|
|
565
|
+
const result = executeConversationTool(
|
|
566
|
+
toolBlock.name,
|
|
567
|
+
toolBlock.input,
|
|
568
|
+
progress,
|
|
569
|
+
messages
|
|
570
|
+
);
|
|
571
|
+
progress = result.progress;
|
|
572
|
+
toolResults.push({
|
|
573
|
+
type: "tool_result",
|
|
574
|
+
tool_use_id: toolBlock.id,
|
|
575
|
+
content: result.response
|
|
576
|
+
});
|
|
577
|
+
if (result.signal === "present_plan") {
|
|
578
|
+
hasPresentPlan = true;
|
|
579
|
+
}
|
|
580
|
+
if (toolBlock.name === "summarize_stage") {
|
|
581
|
+
hasSummarizeStage = true;
|
|
582
|
+
summarizeSummary = toolBlock.input.summary;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
messages.push({ role: "user", content: toolResults });
|
|
586
|
+
if (hasSummarizeStage) {
|
|
587
|
+
const lastAssistant = messages[messages.length - 2];
|
|
588
|
+
const lastUser = messages[messages.length - 1];
|
|
589
|
+
messages.length = 0;
|
|
590
|
+
messages.push({
|
|
591
|
+
role: "assistant",
|
|
592
|
+
content: summarizeSummary
|
|
593
|
+
});
|
|
594
|
+
messages.push({
|
|
595
|
+
role: "user",
|
|
596
|
+
content: "[Continuing]"
|
|
597
|
+
});
|
|
598
|
+
messages.push(lastAssistant);
|
|
599
|
+
messages.push(lastUser);
|
|
600
|
+
}
|
|
601
|
+
if (hasPresentPlan) {
|
|
602
|
+
renderPlan(serializeProgress(progress));
|
|
603
|
+
return progress;
|
|
604
|
+
}
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
const userInput = await getUserInput("Your response");
|
|
608
|
+
if (userInput === null) return null;
|
|
609
|
+
messages.push({
|
|
610
|
+
role: "assistant",
|
|
611
|
+
content: contentBlocks
|
|
612
|
+
});
|
|
613
|
+
messages.push({ role: "user", content: userInput });
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
async function runScaffoldLoop(progress, mcpServers) {
|
|
617
|
+
const messages = [];
|
|
618
|
+
const system = buildScaffoldPrompt(progress);
|
|
619
|
+
const cwd = process.cwd();
|
|
620
|
+
const projectName = progress.projectName;
|
|
621
|
+
const projectDir = join2(cwd, projectName);
|
|
622
|
+
let toolCallCount = 0;
|
|
623
|
+
const maxToolCalls = 30;
|
|
624
|
+
messages.push({
|
|
625
|
+
role: "user",
|
|
626
|
+
content: "Begin scaffolding the project according to the plan."
|
|
627
|
+
});
|
|
628
|
+
while (true) {
|
|
629
|
+
const response = await chat({
|
|
630
|
+
system,
|
|
631
|
+
messages,
|
|
632
|
+
tools: scaffoldToolDefinitions(),
|
|
633
|
+
maxTokens: 16384,
|
|
634
|
+
mcpServers
|
|
635
|
+
});
|
|
636
|
+
const contentBlocks = response.content;
|
|
637
|
+
const toolUseBlocks = contentBlocks.filter(
|
|
638
|
+
(b) => b.type === "tool_use"
|
|
639
|
+
);
|
|
640
|
+
if (toolUseBlocks.length === 0) {
|
|
641
|
+
return true;
|
|
642
|
+
}
|
|
643
|
+
messages.push({ role: "assistant", content: contentBlocks });
|
|
644
|
+
const toolResults = [];
|
|
645
|
+
for (const block of toolUseBlocks) {
|
|
646
|
+
const toolBlock = block;
|
|
647
|
+
toolCallCount++;
|
|
648
|
+
if (toolCallCount > maxToolCalls) {
|
|
649
|
+
renderError(`Tool call limit exceeded (${maxToolCalls}). Stopping scaffold loop.`);
|
|
650
|
+
return false;
|
|
651
|
+
}
|
|
652
|
+
const spinner2 = createSpinner();
|
|
653
|
+
try {
|
|
654
|
+
if (toolBlock.name === "run_scaffold") {
|
|
655
|
+
spinner2.start(`Running scaffold: ${toolBlock.input.tool}`);
|
|
656
|
+
const approvedTool = findApprovedScaffoldTool(progress);
|
|
657
|
+
const outputDir = runScaffold(
|
|
658
|
+
toolBlock.input.tool,
|
|
659
|
+
toolBlock.input.args,
|
|
660
|
+
approvedTool,
|
|
661
|
+
projectName,
|
|
662
|
+
cwd
|
|
663
|
+
);
|
|
664
|
+
spinner2.stop(`Scaffold complete: ${outputDir}`);
|
|
665
|
+
toolResults.push({
|
|
666
|
+
type: "tool_result",
|
|
667
|
+
tool_use_id: toolBlock.id,
|
|
668
|
+
content: `Scaffold completed. Project created at ${outputDir}`
|
|
669
|
+
});
|
|
670
|
+
} else if (toolBlock.name === "add_integration") {
|
|
671
|
+
const integrationDesc = Object.keys(
|
|
672
|
+
toolBlock.input.files ?? {}
|
|
673
|
+
).join(", ");
|
|
674
|
+
spinner2.start(`Adding integration: ${integrationDesc}`);
|
|
675
|
+
writeIntegration(projectDir, {
|
|
676
|
+
files: toolBlock.input.files ?? {},
|
|
677
|
+
dependencies: toolBlock.input.dependencies,
|
|
678
|
+
devDependencies: toolBlock.input.devDependencies,
|
|
679
|
+
envVars: toolBlock.input.envVars
|
|
680
|
+
});
|
|
681
|
+
spinner2.stop("Integration added");
|
|
682
|
+
toolResults.push({
|
|
683
|
+
type: "tool_result",
|
|
684
|
+
tool_use_id: toolBlock.id,
|
|
685
|
+
content: "Integration written successfully."
|
|
686
|
+
});
|
|
687
|
+
} else {
|
|
688
|
+
spinner2.stop(`Unknown tool: ${toolBlock.name}`);
|
|
689
|
+
toolResults.push({
|
|
690
|
+
type: "tool_result",
|
|
691
|
+
tool_use_id: toolBlock.id,
|
|
692
|
+
content: `Unknown tool: "${toolBlock.name}".`,
|
|
693
|
+
is_error: true
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
} catch (err) {
|
|
697
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
698
|
+
spinner2.stop(`Error: ${errorMessage}`);
|
|
699
|
+
toolResults.push({
|
|
700
|
+
type: "tool_result",
|
|
701
|
+
tool_use_id: toolBlock.id,
|
|
702
|
+
content: errorMessage,
|
|
703
|
+
is_error: true
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
messages.push({ role: "user", content: toolResults });
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
function findApprovedScaffoldTool(progress) {
|
|
711
|
+
const categories = [
|
|
712
|
+
progress.frontend,
|
|
713
|
+
progress.backend,
|
|
714
|
+
progress.database,
|
|
715
|
+
progress.auth,
|
|
716
|
+
progress.payments,
|
|
717
|
+
progress.deployment,
|
|
718
|
+
...progress.extras
|
|
719
|
+
];
|
|
720
|
+
for (const choice of categories) {
|
|
721
|
+
if (choice?.scaffoldTool) {
|
|
722
|
+
return choice.scaffoldTool;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
return "";
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// 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?"
|
|
738
|
+
});
|
|
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!");
|
|
750
|
+
} else {
|
|
751
|
+
renderError("Scaffolding encountered errors. Check the output above.");
|
|
752
|
+
outro2("You may need to fix issues manually.");
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
var command = process.argv[2];
|
|
756
|
+
if (!command || command === "init") {
|
|
757
|
+
main().catch((err) => {
|
|
758
|
+
console.error(err);
|
|
759
|
+
process.exit(1);
|
|
760
|
+
});
|
|
761
|
+
} else {
|
|
762
|
+
console.error(`Unknown command: ${command}`);
|
|
763
|
+
console.error("Usage: stack-agent [init]");
|
|
764
|
+
process.exit(1);
|
|
765
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "stack-agent",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "AI-powered CLI that helps developers choose and scaffold full-stack applications through conversational interaction",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/alainbrown/stack-agent.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/alainbrown/stack-agent/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/alainbrown/stack-agent#readme",
|
|
15
|
+
"keywords": [
|
|
16
|
+
"cli",
|
|
17
|
+
"scaffold",
|
|
18
|
+
"fullstack",
|
|
19
|
+
"ai",
|
|
20
|
+
"agent",
|
|
21
|
+
"developer-tools",
|
|
22
|
+
"project-setup",
|
|
23
|
+
"claude",
|
|
24
|
+
"llm"
|
|
25
|
+
],
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=20"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist"
|
|
31
|
+
],
|
|
32
|
+
"bin": {
|
|
33
|
+
"stack-agent": "dist/index.js"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"dev": "tsx src/index.ts",
|
|
37
|
+
"build": "tsup",
|
|
38
|
+
"test": "vitest run",
|
|
39
|
+
"test:watch": "vitest"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@anthropic-ai/sdk": "^0.78.0",
|
|
43
|
+
"@clack/prompts": "^1.1.0",
|
|
44
|
+
"marked": "^15.0.12",
|
|
45
|
+
"marked-terminal": "^7.3.0",
|
|
46
|
+
"zod": "^4.3.6"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/marked-terminal": "^6.1.1",
|
|
50
|
+
"@types/node": "^25.5.0",
|
|
51
|
+
"tsup": "^8.5.1",
|
|
52
|
+
"tsx": "^4.21.0",
|
|
53
|
+
"typescript": "^5.9.3",
|
|
54
|
+
"vitest": "^4.1.0"
|
|
55
|
+
}
|
|
56
|
+
}
|