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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +44 -0
  3. package/dist/index.js +765 -0
  4. 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
+ }