stack-agent 0.3.0 → 0.3.1

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 +7 -19
  2. package/dist/index.js +86 -51
  3. package/package.json +1 -3
package/README.md CHANGED
@@ -5,22 +5,16 @@
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
6
6
  [![Node.js](https://img.shields.io/node/v/stack-agent)](https://nodejs.org)
7
7
 
8
- AI-powered CLI that helps developers choose and scaffold full-stack applications through conversational interaction.
8
+ AI-powered CLI that helps developers choose and scaffold full-stack applications through a fullscreen terminal UI.
9
9
 
10
- A senior software architect in your terminal — it walks you through stack decisions, explains trade-offs, and scaffolds your project using official framework tools.
10
+ A senior software architect in your terminal — it recommends your entire stack, lets you review and refine each decision, then scaffolds the project with integration code.
11
11
 
12
- ## How it works
13
-
14
- 1. **Conversation** — The agent asks what you're building, then guides you through frontend, backend, database, auth, payments, AI/LLM, and deployment choices
15
- 2. **Recommendations** — Each stage presents 2-3 options with a recommended pick and trade-off context
16
- 3. **Review** — Once all decisions are made, the agent presents your full stack for approval
17
- 4. **Scaffold** — The agent runs official tools (create-next-app, create-vite, etc.) and generates integration code grounded by current documentation
18
-
19
- ## Quick start
12
+ ## Usage
20
13
 
21
14
  ```bash
22
15
  export ANTHROPIC_API_KEY=your-key-here
23
- npx stack-agent
16
+ npx stack-agent # Start or resume a session
17
+ npx stack-agent --fresh # Clear saved progress and start over
24
18
  ```
25
19
 
26
20
  ## Requirements
@@ -28,19 +22,13 @@ npx stack-agent
28
22
  - Node.js 20+
29
23
  - An [Anthropic API key](https://console.anthropic.com/settings/keys)
30
24
 
31
- ## What it does
32
-
33
- - Delegates base scaffolding to official framework CLIs (create-next-app, create-vite, etc.)
34
- - Generates integration code (auth, database, payments) using Claude, grounded by up-to-date documentation via MCP
35
- - Writes `.env.example` with required environment variables
36
- - Installs dependencies automatically
37
-
38
25
  ## Development
39
26
 
40
27
  ```bash
41
28
  npm install
42
29
  npm run dev # Run with tsx
43
- npm test # Run tests
30
+ npm run mockup # Run interactive TUI mockup (no LLM calls)
31
+ npm test # Run tests (176 tests)
44
32
  npm run build # Build with tsup
45
33
  ```
46
34
 
package/dist/index.js CHANGED
@@ -262,12 +262,12 @@ function deserializeSession(json) {
262
262
 
263
263
  // src/cli/components/stage-list.tsx
264
264
  import { jsx as jsx5, jsxs as jsxs3 } from "react/jsx-runtime";
265
- function StageListView({ stages, currentStageId, progress, onResult }) {
265
+ function StageListView({ stages, currentStageId, progress, showFresh = false, onResult }) {
266
266
  const [showConfirm, setShowConfirm] = useState3(false);
267
267
  const [showWarning, setShowWarning] = useState3("");
268
268
  const [cursor, setCursor] = useState3(0);
269
269
  const canBuild = isComplete(progress);
270
- const itemCount = stages.length + 1;
270
+ const itemCount = stages.length + 1 + (showFresh ? 1 : 0);
271
271
  useInput2((input, key) => {
272
272
  if (showConfirm) return;
273
273
  if (key.upArrow) {
@@ -281,13 +281,15 @@ function StageListView({ stages, currentStageId, progress, onResult }) {
281
281
  if (key.return) {
282
282
  if (cursor < stages.length) {
283
283
  onResult({ kind: "select", stageId: stages[cursor].id });
284
- } else {
284
+ } else if (cursor === stages.length) {
285
285
  if (canBuild) {
286
286
  setShowConfirm(true);
287
287
  } else {
288
288
  const missing = getMissingDecisions(progress);
289
289
  setShowWarning(`Complete ${missing.join(", ")} first.`);
290
290
  }
291
+ } else {
292
+ onResult({ kind: "fresh" });
291
293
  }
292
294
  }
293
295
  if (key.escape) {
@@ -325,6 +327,10 @@ function StageListView({ stages, currentStageId, progress, onResult }) {
325
327
  requiredRemaining(progress),
326
328
  " remaining)"
327
329
  ] })
330
+ ] }),
331
+ showFresh && /* @__PURE__ */ jsxs3(Box5, { children: [
332
+ /* @__PURE__ */ jsx5(Text5, { children: cursor === stages.length + 1 ? "\u276F " : " " }),
333
+ /* @__PURE__ */ jsx5(Text5, { dimColor: true, children: "Start fresh" })
328
334
  ] })
329
335
  ] });
330
336
  }
@@ -471,10 +477,10 @@ function ScaffoldView({ steps }) {
471
477
  function createBridge() {
472
478
  const listeners = /* @__PURE__ */ new Map();
473
479
  let pendingResolve = null;
474
- function emit(event, ...args2) {
480
+ function emit(event, ...args) {
475
481
  const set = listeners.get(event);
476
482
  if (set) {
477
- for (const fn of set) fn(...args2);
483
+ for (const fn of set) fn(...args);
478
484
  }
479
485
  }
480
486
  return {
@@ -511,20 +517,48 @@ import { join as join2 } from "path";
511
517
  import Anthropic from "@anthropic-ai/sdk";
512
518
 
513
519
  // 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);
520
+ var LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
521
+ var currentLevel = process.env.LOG_LEVEL ?? "info";
522
+ function log(level, component, msg, data) {
523
+ if (LEVELS[level] < LEVELS[currentLevel]) return;
524
+ const prefix = `[${level}] [${component}]`;
525
+ const line = data ? `${prefix} ${msg} ${JSON.stringify(data)}` : `${prefix} ${msg}`;
526
+ process.stderr.write(line + "\n");
527
+ }
519
528
  function createLogger(component) {
520
- return logger.child({ component });
529
+ return {
530
+ debug: (data, msg) => log("debug", component, msg, data),
531
+ info: (data, msg) => log("info", component, msg, data),
532
+ warn: (data, msg) => log("warn", component, msg, data),
533
+ error: (data, msg) => log("error", component, msg, data)
534
+ };
521
535
  }
522
536
 
523
537
  // src/llm/client.ts
524
- var log = createLogger("llm");
538
+ var log2 = createLogger("llm");
525
539
  function summarizeMessages(messages) {
526
540
  return `${messages.length} messages, last: ${messages.at(-1)?.role ?? "none"}`;
527
541
  }
542
+ function withMessageCaching(messages) {
543
+ if (messages.length < 2) return messages;
544
+ const cached = messages.map((m, i) => {
545
+ if (i !== messages.length - 2) return m;
546
+ const content = m.content;
547
+ if (typeof content === "string") {
548
+ return { ...m, content: [{ type: "text", text: content, cache_control: { type: "ephemeral" } }] };
549
+ }
550
+ if (Array.isArray(content)) {
551
+ const blocks = [...content];
552
+ const last = blocks[blocks.length - 1];
553
+ if (last) {
554
+ blocks[blocks.length - 1] = { ...last, cache_control: { type: "ephemeral" } };
555
+ }
556
+ return { ...m, content: blocks };
557
+ }
558
+ return m;
559
+ });
560
+ return cached;
561
+ }
528
562
  function getClient() {
529
563
  const apiKey = process.env.ANTHROPIC_API_KEY;
530
564
  if (!apiKey) {
@@ -543,7 +577,7 @@ function client() {
543
577
  }
544
578
  async function chat(options) {
545
579
  const { system, messages, tools, maxTokens, mcpServers } = options;
546
- log.debug({ maxTokens, toolCount: tools?.length ?? 0, messages: summarizeMessages(messages) }, "chat request");
580
+ log2.debug({ maxTokens, toolCount: tools?.length ?? 0, messages: summarizeMessages(messages) }, "chat request");
547
581
  let response;
548
582
  if (mcpServers && Object.keys(mcpServers).length > 0) {
549
583
  const mcpServerList = Object.entries(mcpServers).map(([name, config]) => ({
@@ -573,39 +607,39 @@ async function chat(options) {
573
607
  response = await client().messages.create({
574
608
  model: "claude-sonnet-4-6",
575
609
  max_tokens: maxTokens,
576
- system,
577
- messages,
610
+ system: [{ type: "text", text: system, cache_control: { type: "ephemeral" } }],
611
+ messages: withMessageCaching(messages),
578
612
  ...tools && tools.length > 0 && { tools }
579
613
  });
580
614
  }
581
- log.info({
615
+ log2.info({
582
616
  stopReason: response.stop_reason,
583
617
  contentBlocks: response.content.length,
584
618
  usage: response.usage
585
619
  }, "chat response");
586
- log.debug({ content: response.content }, "chat response content");
620
+ log2.debug({ content: response.content }, "chat response content");
587
621
  return response;
588
622
  }
589
623
  async function chatStream(options, callbacks) {
590
624
  const { system, messages, tools, maxTokens } = options;
591
- log.debug({ maxTokens, toolCount: tools?.length ?? 0, messages: summarizeMessages(messages) }, "chatStream request");
625
+ log2.debug({ maxTokens, toolCount: tools?.length ?? 0, messages: summarizeMessages(messages) }, "chatStream request");
592
626
  const stream = client().messages.stream({
593
627
  model: "claude-sonnet-4-6",
594
628
  max_tokens: maxTokens,
595
- system,
596
- messages,
629
+ system: [{ type: "text", text: system, cache_control: { type: "ephemeral" } }],
630
+ messages: withMessageCaching(messages),
597
631
  ...tools && tools.length > 0 && { tools }
598
632
  });
599
633
  stream.on("text", (text) => {
600
634
  callbacks.onText(text);
601
635
  });
602
636
  const finalMessage = await stream.finalMessage();
603
- log.info({
637
+ log2.info({
604
638
  stopReason: finalMessage.stop_reason,
605
639
  contentBlocks: finalMessage.content.length,
606
640
  usage: finalMessage.usage
607
641
  }, "chatStream response");
608
- log.debug({ content: finalMessage.content }, "chatStream response content");
642
+ log2.debug({ content: finalMessage.content }, "chatStream response content");
609
643
  for (const block of finalMessage.content) {
610
644
  if (block.type === "tool_use") {
611
645
  callbacks.onToolUse(block);
@@ -905,7 +939,7 @@ Do not ask for confirmation. Proceed through all steps automatically.`;
905
939
  import { execFileSync } from "child_process";
906
940
  import { readdirSync, existsSync } from "fs";
907
941
  import { join } from "path";
908
- function runScaffold(tool, args2, projectName, cwd) {
942
+ function runScaffold(tool, args, projectName, cwd) {
909
943
  const outputDir = join(cwd, projectName);
910
944
  if (existsSync(outputDir)) {
911
945
  const entries = readdirSync(outputDir);
@@ -915,7 +949,7 @@ function runScaffold(tool, args2, projectName, cwd) {
915
949
  );
916
950
  }
917
951
  }
918
- const spawnArgs = [`${tool}@latest`, projectName, ...args2];
952
+ const spawnArgs = [`${tool}@latest`, projectName, ...args];
919
953
  const opts = { cwd, stdio: "pipe" };
920
954
  execFileSync("npx", spawnArgs, opts);
921
955
  return outputDir;
@@ -1203,7 +1237,7 @@ async function runScaffoldLoop(progress, onProgress, mcpServers) {
1203
1237
  }
1204
1238
 
1205
1239
  // src/agent/recommend.ts
1206
- var log2 = createLogger("recommend");
1240
+ var log3 = createLogger("recommend");
1207
1241
  var RECOMMEND_PROMPT = `You are a senior software architect. Based on the project description, recommend a complete technology stack.
1208
1242
 
1209
1243
  For each category, provide your recommendation as a JSON object. If a category is not needed for this project, set it to null.
@@ -1237,15 +1271,15 @@ Description: ${description}`
1237
1271
  maxTokens: 1024
1238
1272
  });
1239
1273
  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");
1274
+ log3.info({ rawLength: text.length }, "received recommendation response");
1275
+ log3.debug({ rawText: text }, "recommendation raw text");
1242
1276
  const jsonStr = text.replace(/^```(?:json)?\s*\n?/m, "").replace(/\n?```\s*$/m, "").trim();
1243
1277
  const parsed = JSON.parse(jsonStr);
1244
1278
  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");
1279
+ log3.info({ recommended: categories, skipped: Object.keys(parsed).filter((k) => parsed[k] === null) }, "parsed recommendations");
1246
1280
  return parsed;
1247
1281
  } catch (err) {
1248
- log2.error({ err }, "recommendation pass failed");
1282
+ log3.error({ err }, "recommendation pass failed");
1249
1283
  return {};
1250
1284
  }
1251
1285
  }
@@ -1290,7 +1324,7 @@ function applyRecommendations(progress, stages, recommendations) {
1290
1324
 
1291
1325
  // src/cli/app.tsx
1292
1326
  import { jsx as jsx8, jsxs as jsxs6 } from "react/jsx-runtime";
1293
- function App({ manager, onBuild, onExit }) {
1327
+ function App({ manager, isResumed, onBuild, onFresh, onExit }) {
1294
1328
  const app = useApp();
1295
1329
  const { width, height } = useScreenSize();
1296
1330
  const [view, setView] = useState5(
@@ -1372,6 +1406,9 @@ function App({ manager, onBuild, onExit }) {
1372
1406
  syncState();
1373
1407
  }
1374
1408
  runStage(result.stageId);
1409
+ } else if (result.kind === "fresh") {
1410
+ onFresh();
1411
+ app.exit();
1375
1412
  } else if (result.kind === "build") {
1376
1413
  setView("scaffold");
1377
1414
  setScaffoldSteps([]);
@@ -1458,6 +1495,7 @@ function App({ manager, onBuild, onExit }) {
1458
1495
  stages,
1459
1496
  currentStageId: currentStage?.id ?? null,
1460
1497
  progress,
1498
+ showFresh: isResumed,
1461
1499
  onResult: handleStageResult
1462
1500
  }
1463
1501
  ),
@@ -1992,63 +2030,60 @@ What needs to change?`;
1992
2030
  }
1993
2031
  };
1994
2032
  }
1995
- async function main(fresh = false) {
2033
+ async function main(startFresh = false) {
1996
2034
  const cwd = process.cwd();
1997
2035
  const invalidationFn = createInvalidationFn();
1998
2036
  process.on("SIGINT", () => {
1999
2037
  process.exit(0);
2000
2038
  });
2001
- if (fresh) {
2039
+ if (startFresh) {
2002
2040
  const tempManager = StageManager.resume(cwd);
2003
2041
  tempManager?.cleanup();
2004
- console.log("Session cleared. Starting fresh.\n");
2005
2042
  }
2006
2043
  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");
2044
+ const isResumed = !startFresh && StageManager.detect(cwd) !== null;
2045
+ if (isResumed) {
2012
2046
  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
- }
2047
+ manager = resumed ?? StageManager.start(cwd, invalidationFn);
2019
2048
  } else {
2020
2049
  manager = StageManager.start(cwd, invalidationFn);
2021
2050
  }
2022
2051
  let shouldBuild = false;
2052
+ let shouldStartFresh = false;
2023
2053
  const ink = withFullScreen(
2024
2054
  React6.createElement(App, {
2025
2055
  manager,
2056
+ isResumed,
2026
2057
  onBuild: () => {
2027
2058
  shouldBuild = true;
2028
2059
  },
2060
+ onFresh: () => {
2061
+ shouldStartFresh = true;
2062
+ },
2029
2063
  onExit: () => {
2030
- shouldBuild = false;
2031
2064
  }
2032
2065
  })
2033
2066
  );
2034
2067
  await ink.start();
2035
2068
  await ink.waitUntilExit();
2069
+ if (shouldStartFresh) {
2070
+ manager.cleanup();
2071
+ return main(true);
2072
+ }
2036
2073
  if (shouldBuild) {
2037
2074
  const readiness = manager.progress.deployment ? checkDeployReadiness(manager.progress.deployment.component) : null;
2038
2075
  renderPostScaffold(manager.progress.projectName, readiness);
2039
2076
  console.log("\nHappy building!\n");
2040
2077
  }
2041
2078
  }
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) => {
2079
+ var command = process.argv[2];
2080
+ if (!command || command === "init") {
2081
+ main().catch((err) => {
2047
2082
  console.error(err);
2048
2083
  process.exit(1);
2049
2084
  });
2050
2085
  } else {
2051
2086
  console.error(`Unknown command: ${command}`);
2052
- console.error("Usage: stack-agent [init] [--fresh]");
2087
+ console.error("Usage: stack-agent [init]");
2053
2088
  process.exit(1);
2054
2089
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stack-agent",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "AI-powered CLI that helps developers choose and scaffold full-stack applications through conversational interaction",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -46,7 +46,6 @@
46
46
  "ink": "^6.8.0",
47
47
  "marked": "^15.0.12",
48
48
  "marked-terminal": "^7.3.0",
49
- "pino": "^10.3.1",
50
49
  "react": "^19.2.4",
51
50
  "zod": "^4.3.6"
52
51
  },
@@ -55,7 +54,6 @@
55
54
  "@types/node": "^25.5.0",
56
55
  "@types/react": "^19.2.14",
57
56
  "ink-testing-library": "^4.0.0",
58
- "pino-pretty": "^13.1.3",
59
57
  "tsup": "^8.5.1",
60
58
  "tsx": "^4.21.0",
61
59
  "typescript": "^5.9.3",