@benjaminthomas/spec-driven-dev 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -116,8 +116,12 @@ say **"run spec-setup"**: that does a deeper version that also reads `CLAUDE.md`
116
116
  config, and recent PRs, and fills in what the installer can't (such as each repo's role, or commands
117
117
  for stacks other than Node). If a skill finds no config, it runs this setup first.
118
118
 
119
- **What `spec-driven-dev` asks, in order** (every answer comes pre-filled with what it detected, so you
120
- can usually just press Enter):
119
+ **What `spec-driven-dev` asks, in order.** Each question is a menu: move with **↑/↓**, tick with
120
+ **Space**, and confirm with **Enter**. What it detected is pre-selected and marked *(detected)*, so you
121
+ can usually just press Enter. Repo paths are added one at a time: paste a path (quotes, `~`, and
122
+ Windows paths like `C:\work\api` all work). A path that doesn't exist or isn't a git repo is flagged
123
+ straight away, so you can fix it before moving on. Press Enter on an empty line when you're done.
124
+ **Ctrl+C** cancels without changing anything.
121
125
 
122
126
  | Question | Detected from | Example answer |
123
127
  |---|---|---|
@@ -148,12 +152,16 @@ can usually just press Enter):
148
152
  example, from inside `/Projects/frontend`:
149
153
 
150
154
  ```
151
- Layout — single, monorepo, or multi-repo (detected: single) [single]: multi-repo
152
- Found 1 git repos under /Projects. Which ones belong to this workspace?
153
- 1. frontend
154
- Repos [1]:
155
- Other repo paths, comma-separated (empty for none): ~/Work/backend, /srv/database
156
- Where should this workspace's .spec-driven.yml live [~/.spec-driven/frontend-workspace]:
155
+ ✔ How is this project organised? › Multiple repos
156
+ ✔ Which of these repos (found under /Projects) belong to this workspace? › frontend
157
+ ? Add a repo from another folder — paste its path, or press Enter to finish (1 selected) › ~/Work/nope
158
+ › Folder not found: /Users/you/Work/nope
159
+ ? Add a repo from another folder — paste its path, or press Enter to finish (1 selected) › ~/Work/backend
160
+ ✓ added /Users/you/Work/backend
161
+ ? Add a repo from another folder — paste its path, or press Enter to finish (2 selected) › /srv/database
162
+ ✓ added /srv/database
163
+ ? Add a repo from another folder — paste its path, or press Enter to finish (3 selected) ›
164
+ ✔ Folder for this workspace's settings file (.spec-driven.yml) › ~/.spec-driven/frontend-workspace
157
165
  ```
158
166
 
159
167
  - **Where the config goes:** the repos' shared parent folder, or `~/.spec-driven/<name>/` when that
@@ -375,6 +383,13 @@ run one after another, stopping at the first failure. Two steps always wait for
375
383
  spec, and pushing or opening the PR. You don't need slash commands either. Saying "investigate
376
384
  STP-123" or "ship it" picks the right skill.
377
385
 
386
+ **Setup didn't ask me anything.**
387
+ Update to 1.5.1 or later (`npx @benjaminthomas/spec-driven-dev@latest --version`). Earlier versions
388
+ crashed on Node 14 or 16, which nvm often switches to inside older projects, and wrote a config
389
+ silently when there was no terminal. Now it runs on Node 14+. With no terminal to answer in (a `!`
390
+ command in Claude Code, an editor task, piped input), it stops, changes nothing, and tells you to run
391
+ it in a terminal window or pass flags such as `--layout=multi-repo --repos=…`.
392
+
378
393
  **Setup didn't ask me about repos.**
379
394
  It only asks about repos for a multi-repo or monorepo layout. If it detected `single`, type
380
395
  `multi-repo` at the layout question, then add your other repos by path. If it asked nothing at all,
@@ -401,7 +416,8 @@ touched by an update.
401
416
  Releases are published by this repo's GitHub Actions workflow, not from anyone's laptop, through
402
417
  npm [trusted publishing](https://docs.npmjs.com/trusted-publishers). Each version's npm page shows
403
418
  a provenance badge linking it to the exact commit and workflow run that built it. The package has
404
- no dependencies, and it contains only `bin/cli.mjs` and the `skills/` folder.
419
+ one small dependency (`prompts`, for the setup menus), and it contains only `bin/` and the `skills/`
420
+ folder.
405
421
 
406
422
  **Something isn't working. Where do I start?**
407
423
  Run `spec-driven-dev doctor` in your project. It checks the config against your
@@ -455,7 +471,9 @@ can otherwise reuse an older copy it cached. What changed in each version is in
455
471
  `npx github:benjaminthomas/spec-driven-dev` installs straight from GitHub instead, for example to
456
472
  try an unreleased commit on `main`.
457
473
 
458
- **Requirements:** Node.js 18 or newer and git. The installer has no dependencies.
474
+ **Requirements:** Node.js 14 or newer and git, on macOS, Linux, or Windows (PowerShell or cmd). The
475
+ installer has one small dependency, [`prompts`](https://www.npmjs.com/package/prompts), for its menus.
476
+ CI tests it on Node 14, 16, 18, 20, 22, and 24, and on Windows and macOS.
459
477
 
460
478
  ### Claude Code (plugin, with automatic updates)
461
479
 
@@ -551,7 +569,8 @@ skills/<name>/SKILL.md the skill itself (frontmatter: name, description,
551
569
  skills/<name>/references/ templates and prompts the skill loads when needed
552
570
  .claude-plugin/ Claude Code plugin + marketplace manifests
553
571
  .agents/skills/ symlinks so Codex/Antigravity pick the skills up inside this repo
554
- bin/cli.mjs the `spec-driven-dev` CLI: installer, setup, doctor, status (no dependencies)
572
+ bin/spec-driven-dev.cjs launcher (runs on any Node, explains if it's too old)
573
+ bin/cli.mjs the `spec-driven-dev` CLI: installer, setup menus, doctor, status
555
574
  package.json npm package (ships bin/ and skills/)
556
575
  mcp-server/ MCP fallback server
557
576
  hooks/hooks.json Claude Code session-start hook (runs `cli.mjs status`)
package/bin/cli.mjs CHANGED
@@ -13,7 +13,7 @@ import fs from "node:fs";
13
13
  import os from "node:os";
14
14
  import path from "node:path";
15
15
  import { execFileSync } from "node:child_process";
16
- import { createInterface } from "node:readline/promises";
16
+ import prompts from "prompts"; // arrow-key menus that work in PowerShell, cmd, macOS and Linux terminals
17
17
  import { fileURLToPath } from "node:url";
18
18
 
19
19
  const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
@@ -40,27 +40,46 @@ if (flag("version") || flag("v")) {
40
40
  console.log(JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version);
41
41
  process.exit(0);
42
42
  }
43
- if (["install", "setup"].includes(command) && !process.stdin.isTTY && !flag("yes") && !flag("y"))
44
- console.log("• No interactive terminal detected, so no questions will be asked: using the detected values.\n Run it directly in a terminal to answer them, or pass flags (--layout, --repos, --tools …). Add --yes to hide this note.");
43
+ // No terminal to answer in (e.g. `!` in Claude Code, an IDE task, a pipe) and nothing passed explicitly:
44
+ // stop rather than write a config from guesses.
45
+ const ANSWER_FLAGS = ["yes", "y", "layout", "repos", "packages", "tools", "workspace", "after-approval", "skills", "global", "no-setup"];
46
+ if (["install", "setup"].includes(command) && !process.stdin.isTTY && !args.some((a) => ANSWER_FLAGS.some((f) => a === `--${f}` || a.startsWith(`--${f}=`)))) {
47
+ console.log(`✗ No interactive terminal, so setup can't ask its questions. Nothing was changed.
48
+
49
+ This happens when it runs through another program: a "!" command in Claude Code, an editor task,
50
+ or piped input. Either:
51
+ • run it yourself in a terminal window (Terminal, iTerm, or your editor's terminal panel):
52
+ npx @benjaminthomas/spec-driven-dev@latest ${command === "setup" ? "setup" : ""}
53
+ • or give the answers as flags, for example:
54
+ npx @benjaminthomas/spec-driven-dev@latest setup --layout=multi-repo --repos=~/Projects/frontend,~/Projects/backend
55
+ (add --yes to accept everything it detects)`);
56
+ process.exit(1);
57
+ }
45
58
 
46
59
  if (flag("help") || flag("h")) {
47
60
  console.log(fs.readFileSync(fileURLToPath(import.meta.url), "utf8").split("\n").slice(1, 11).join("\n").replace(/^\/\/ ?/gm, ""));
48
61
  process.exit(0);
49
62
  }
50
63
 
51
- const rl = YES ? null : createInterface({ input: process.stdin, output: process.stdout });
52
- async function ask(q, def = "") {
53
- if (YES) return def;
54
- const a = (await rl.question(`${q}${def ? ` [${def}]` : ""}: `)).trim();
55
- return a || def;
64
+ // Every question goes through these helpers. With --yes (or answers given as flags) they return the default.
65
+ const cancelled = () => { console.log("\n✗ Setup cancelled. Nothing was changed."); process.exit(1); };
66
+ async function ask(q) { const r = await prompts({ name: "v", ...q }, { onCancel: cancelled }); return r.v; }
67
+ async function askText(message, initial = "", validate) {
68
+ if (YES) return initial;
69
+ return String((await ask({ type: "text", message, initial, validate })) ?? "").trim();
70
+ }
71
+ async function askSelect(message, choices, initialValue) {
72
+ if (YES) return initialValue;
73
+ const initial = Math.max(0, choices.findIndex((c) => c.value === initialValue));
74
+ return ask({ type: "select", message, choices, initial, hint: "↑/↓ then Enter" });
56
75
  }
57
- async function pick(q, items, defaults) {
58
- items.forEach((it, i) => console.log(` ${i + 1}. ${it.label}`));
59
- const defIdx = items.map((it, i) => (defaults.includes(it.value) ? i + 1 : null)).filter(Boolean).join(",");
60
- const a = await ask(`${q} (comma-separated numbers, "all", or "none")`, defIdx || "none");
61
- if (a === "all") return items.map((it) => it.value);
62
- if (a === "none") return [];
63
- return a.split(",").map((n) => items[Number(n.trim()) - 1]?.value).filter(Boolean);
76
+ async function askMulti(message, choices) {
77
+ if (YES) return choices.filter((c) => c.selected).map((c) => c.value);
78
+ return ask({ type: "multiselect", message, choices, instructions: false, hint: "↑/↓ move · Space select · A all · Enter confirm" });
79
+ }
80
+ async function askConfirm(message, initial = true) {
81
+ if (YES) return initial;
82
+ return ask({ type: "toggle", message, initial, active: "yes", inactive: "no" });
64
83
  }
65
84
 
66
85
  const run = (cmd, argv, cwd) => {
@@ -68,14 +87,23 @@ const run = (cmd, argv, cwd) => {
68
87
  };
69
88
  const onPath = (bin) => !!run(process.platform === "win32" ? "where" : "which", [bin]);
70
89
 
90
+ // fs.cpSync needs Node 16.7+; this keeps Node 14 working. Follows symlinks, skips .DS_Store.
91
+ function copyDir(from, to) {
92
+ fs.mkdirSync(to, { recursive: true });
93
+ for (const e of fs.readdirSync(from)) {
94
+ if (e === ".DS_Store") continue;
95
+ const src = path.join(from, e), dst = path.join(to, e);
96
+ if (fs.statSync(src).isDirectory()) copyDir(src, dst);
97
+ else fs.copyFileSync(src, dst);
98
+ }
99
+ }
100
+
71
101
  // ---------- install ----------
72
102
  async function install() {
73
103
  const detected = Object.keys(TOOLS).filter((t) => fs.existsSync(path.join(HOME, TOOLS[t].home)) || onPath(TOOLS[t].bin));
74
104
  let tools = opt("tools")?.split(",");
75
- if (!tools) {
76
- console.log("\nWhich AI coding tools should get the skills? (detected ones are pre-selected)");
77
- tools = await pick("Tools", Object.entries(TOOLS).map(([value, t]) => ({ value, label: t.label + (detected.includes(value) ? " ✓ detected" : "") })), detected);
78
- }
105
+ if (!tools) tools = await askMulti("Which AI coding tools should get the skills?",
106
+ Object.entries(TOOLS).map(([value, t]) => ({ value, title: t.label + (detected.includes(value) ? " (installed)" : ""), selected: detected.includes(value) })));
79
107
  const bad = tools.filter((t) => !TOOLS[t]);
80
108
  if (bad.length) throw new Error(`Unknown tool(s): ${bad.join(", ")} — expected ${Object.keys(TOOLS).join(", ")}`);
81
109
 
@@ -86,7 +114,10 @@ async function install() {
86
114
  tools = tools.filter((t) => t !== "claude");
87
115
  }
88
116
 
89
- const global = flag("global") || (!YES && (await ask("Install for this project only, or globally for all projects? (project/global)", "project")) === "global");
117
+ const global = flag("global") || (await askSelect("Install the skills for…", [
118
+ { title: "This project only", value: false, description: "skills go in this folder, e.g. .cursor/skills/" },
119
+ { title: "All my projects", value: true, description: "skills go in your home folder, e.g. ~/.cursor/skills/" },
120
+ ], false));
90
121
  const skills = opt("skills")?.split(",") || ALL_SKILLS;
91
122
  const unknown = skills.filter((s) => !ALL_SKILLS.includes(s));
92
123
  if (unknown.length) throw new Error(`Unknown skill(s): ${unknown.join(", ")} — expected ${ALL_SKILLS.join(", ")}`);
@@ -96,7 +127,7 @@ async function install() {
96
127
  for (const s of skills) {
97
128
  const to = path.join(dest, s);
98
129
  fs.rmSync(to, { recursive: true, force: true });
99
- fs.cpSync(path.join(SKILLS_DIR, s), to, { recursive: true, dereference: true, filter: (f) => !f.endsWith(".DS_Store") });
130
+ copyDir(path.join(SKILLS_DIR, s), to);
100
131
  }
101
132
  console.log(`✓ ${skills.length} skills → ${dest}`);
102
133
  }
@@ -128,7 +159,7 @@ function findRepos(dir, depth = 2, prefix = "") {
128
159
 
129
160
  function detectLayout(dir) {
130
161
  if (isRepo(dir)) {
131
- const top = run("git", ["rev-parse", "--show-toplevel"], dir);
162
+ const top = path.resolve(run("git", ["rev-parse", "--show-toplevel"], dir)); // C:/x → C:\x on Windows
132
163
  const pkg = readJson(path.join(top, "package.json"));
133
164
  const cargo = fs.existsSync(path.join(top, "Cargo.toml")) && fs.readFileSync(path.join(top, "Cargo.toml"), "utf8").includes("[workspace]");
134
165
  const hasManifest = (rel) => MANIFESTS.some((m) => fs.existsSync(path.join(top, rel, m)));
@@ -218,14 +249,25 @@ async function chooseLayout(detected) {
218
249
  const forced = opt("layout");
219
250
  if (forced && !LAYOUTS.includes(forced)) throw new Error(`--layout must be one of ${LAYOUTS.join(", ")}`);
220
251
  if (forced) return forced;
221
- for (;;) {
222
- const a = await ask(`Layout — single, monorepo, or multi-repo (detected: ${detected})`, detected);
223
- if (LAYOUTS.includes(a)) return a;
224
- console.log(` Please type one of: ${LAYOUTS.join(", ")}`);
225
- }
252
+ const tag = (v) => (v === detected ? " (detected)" : "");
253
+ return askSelect("How is this project organised?", [
254
+ { title: `Single repo${tag("single")}`, value: "single", description: "one git repo, one app" },
255
+ { title: `Monorepo${tag("monorepo")}`, value: "monorepo", description: "one git repo with several apps or packages" },
256
+ { title: `Multiple repos${tag("multi-repo")}`, value: "multi-repo", description: "several git repos that change together, in any folders" },
257
+ ], detected);
226
258
  }
227
259
 
228
- const expandHome = (p) => p.replace(/^~(?=$|\/)/, HOME);
260
+ const expandHome = (p) => p.replace(/^~(?=$|[\\/])/, HOME);
261
+ // Accept what people paste: surrounding quotes, ~, relative paths, Windows or POSIX separators.
262
+ const cleanPath = (v) => path.resolve(DIR, expandHome(String(v).trim().replace(/^(["'])(.*)\1$/, "$2").trim()));
263
+ const gitTop = (p) => { const t = run("git", ["rev-parse", "--show-toplevel"], p); return t ? path.resolve(t) : p; }; // git prints C:/x on Windows
264
+ function repoPathProblem(v) {
265
+ if (!String(v || "").trim()) return null;
266
+ const p = cleanPath(v);
267
+ if (!fs.existsSync(p)) return `Folder not found: ${p}`;
268
+ if (!isRepo(p)) return `Not a git repo: ${p}`;
269
+ return null;
270
+ }
229
271
 
230
272
  function commonParent(paths) {
231
273
  if (!paths.length) return "";
@@ -242,27 +284,33 @@ async function chooseRepos(d) {
242
284
  let picked = [];
243
285
  const fromFlag = opt("repos");
244
286
  if (fromFlag) {
245
- picked = splitList(fromFlag).map((p) => path.resolve(DIR, expandHome(p)));
287
+ picked = splitList(fromFlag).map(cleanPath);
246
288
  } else {
247
289
  const found = findRepos(scanRoot);
248
290
  if (found.length) {
249
291
  const owners = {};
250
- for (const r of found) (owners[gitFacts(path.join(scanRoot, r))?.owner || "?"] ||= []).push(r);
292
+ for (const r of found) { const o = gitFacts(path.join(scanRoot, r))?.owner || "?"; (owners[o] = owners[o] || []).push(r); }
251
293
  const biggest = Object.values(owners).sort((a, b) => b.length - a.length)[0] || [];
252
294
  const here = insideRepo ? [path.relative(scanRoot, d.root)] : [];
253
- console.log(`Found ${found.length} git repos under ${scanRoot}. Which ones belong to this workspace?`);
254
- picked = (await pick("Repos", found.map((r) => ({ value: r, label: `${r} (${Object.keys(owners).find((o) => owners[o].includes(r))})` })), [...new Set([...here, ...(insideRepo ? [] : biggest)])]))
295
+ const pre = new Set([...here, ...(insideRepo ? [] : biggest)]);
296
+ picked = (await askMulti(`Which of these repos (found under ${scanRoot}) belong to this workspace?`,
297
+ found.map((r) => { const o = Object.keys(owners).find((k) => owners[k].includes(r)); return { value: r, title: r + (o && o !== "?" ? ` (${o})` : ""), selected: pre.has(r) }; })))
255
298
  .map((r) => path.join(scanRoot, r));
256
299
  } else if (insideRepo) picked = [d.root];
257
- console.log("Repos in other places can be part of the same workspace (e.g. ~/Work/backend, /srv/database).");
258
- const extra = await ask("Other repo paths, comma-separated (empty for none)", "");
259
- picked.push(...splitList(extra).map((p) => path.resolve(DIR, expandHome(p))));
300
+ // Repos elsewhere: one path at a time, checked as you type, so a typo can be fixed straight away.
301
+ if (!YES) for (;;) {
302
+ const v = await askText(`Add a repo from another folder — paste its path, or press Enter to finish${picked.length ? ` (${picked.length} selected)` : ""}`, "",
303
+ (x) => repoPathProblem(x) || true);
304
+ if (!v) break;
305
+ picked.push(cleanPath(v));
306
+ console.log(` ✓ added ${gitTop(cleanPath(v))}`);
307
+ }
260
308
  }
261
309
  const abs = [];
262
310
  for (const p of [...new Set(picked)]) {
263
311
  if (!fs.existsSync(p)) console.log(` ✗ ${p}: folder not found — skipped`);
264
312
  else if (!isRepo(p)) console.log(` ✗ ${p}: not a git repo — skipped`);
265
- else abs.push(run("git", ["rev-parse", "--show-toplevel"], p) || p);
313
+ else abs.push(gitTop(p));
266
314
  }
267
315
  if (!abs.length) throw new Error("A multi-repo workspace needs at least one git repo. Re-run setup and pick or add repos (or use --repos=a,b).");
268
316
 
@@ -275,7 +323,7 @@ async function chooseRepos(d) {
275
323
  const defRoot = opt("workspace") ? path.resolve(DIR, expandHome(opt("workspace")))
276
324
  : allUnderScan ? scanRoot
277
325
  : tooBroad ? path.join(HOME, ".spec-driven", path.basename(abs[0]) + "-workspace") : parent;
278
- const root = path.resolve(DIR, expandHome(await ask("Where should this workspace's .spec-driven.yml live", defRoot)));
326
+ const root = cleanPath(await askText("Folder for this workspace's settings file (.spec-driven.yml)", defRoot));
279
327
  fs.mkdirSync(root, { recursive: true });
280
328
  const repos = [...new Set(abs)].map((p) => relOrAbs(root, p));
281
329
  console.log(` Workspace repos: ${repos.join(", ")}`);
@@ -297,9 +345,17 @@ function register(file, repoPaths) {
297
345
  async function choosePackages(d) {
298
346
  const root = d.root;
299
347
  const fromFlag = opt("packages");
300
- const answer = fromFlag || (await ask("Package folders, comma-separated, relative to the repo root (empty if none yet)", d.repos.join(",")));
348
+ let chosen = fromFlag ? splitList(fromFlag) : d.repos.length
349
+ ? await askMulti("Which packages are part of this monorepo?", d.repos.map((r) => ({ value: r, title: r, selected: true })))
350
+ : [];
351
+ if (!fromFlag && !YES) for (;;) {
352
+ const v = await askText("Add another package folder (relative to the repo root), or press Enter to finish", "",
353
+ (x) => !String(x || "").trim() || fs.existsSync(path.resolve(root, String(x).trim())) || `Folder not found in ${root}`);
354
+ if (!v) break;
355
+ chosen.push(v);
356
+ }
301
357
  const pkgs = [];
302
- for (const p of splitList(answer)) {
358
+ for (const p of chosen) {
303
359
  if (fs.existsSync(path.resolve(root, p))) pkgs.push(relOrAbs(root, path.resolve(root, p)));
304
360
  else console.log(` ✗ ${p}: folder not found in ${root} — skipped`);
305
361
  }
@@ -318,7 +374,7 @@ async function setup() {
318
374
  const { root, repos } = layout === "multi-repo" ? await chooseRepos(d) : layout === "monorepo" ? await choosePackages(d) : { root: d.layout === "multi-repo" ? DIR : d.root, repos: [] };
319
375
  if (layout === "single" && !isRepo(root)) console.log(` • ${root} isn't a git repo — run setup inside the repo, or choose multi-repo.`);
320
376
  const file = path.join(root, ".spec-driven.yml");
321
- if (fs.existsSync(file) && (await ask(`${file} exists — overwrite? (y/n)`, "n")) !== "y") {
377
+ if (fs.existsSync(file) && !(await askConfirm(`${file} already exists. Replace it?`, false))) {
322
378
  console.log("• Kept the existing config. Run the spec-setup skill in your AI tool to review it.");
323
379
  return;
324
380
  }
@@ -328,22 +384,44 @@ async function setup() {
328
384
  const g = infer(facts);
329
385
  if (facts.length) console.log(`Detected: tracker ${g.trackerType}${g.key ? ` (${g.key}-…)` : ""}, base ${g.base}, branches "${g.branchName}", commits ${g.commitStyle}`);
330
386
 
331
- const trackerType = await ask("Issue tracker (jira/github/linear/none)", g.trackerType);
332
- const key = trackerType === "jira" || trackerType === "linear" ? await ask("Ticket key prefix (e.g. STP)", g.key) : "";
333
- const url = trackerType === "none" ? "" : await ask("Tracker URL (optional)", "");
334
- const base = await ask('Base branch work is cut from ("default" = repo default branch)', g.base);
335
- const pr = await ask("Branch PRs target", base);
336
- const branchName = await ask("Branch name pattern ({ticket} {slug} {type})", g.branchName);
337
- const commitStyle = await ask("Commit style (conventional/ticket/freeform)", g.commitStyle);
338
- const specsDir = await ask("Where specs/<feature>/ folders go", "specs");
339
- console.log("After you approve a spec, the workflow can stop and wait for you at each stage, or run");
340
- console.log("implement → verify → checkpoint in one go (it stops at any failure, and never pushes without asking).");
341
- let afterApproval = opt("after-approval");
342
- if (!afterApproval) for (;;) {
343
- afterApproval = await ask("After approval: stop or run-through", "stop");
344
- if (["stop", "run-through"].includes(afterApproval)) break;
345
- console.log(" Please type stop or run-through");
346
- }
387
+ const det = (v, d) => (v === d ? " (detected)" : "");
388
+ const trackerType = await askSelect("Where do your tickets live?", [
389
+ { title: `Jira${det("jira", g.trackerType)}`, value: "jira" },
390
+ { title: `GitHub Issues${det("github", g.trackerType)}`, value: "github" },
391
+ { title: `Linear${det("linear", g.trackerType)}`, value: "linear" },
392
+ { title: `No tracker${det("none", g.trackerType)}`, value: "none", description: "work from transcripts, docs, or plain descriptions" },
393
+ ], g.trackerType);
394
+ const key = trackerType === "jira" || trackerType === "linear" ? (await askText("Ticket key prefix (the STP in STP-123)", g.key)).toUpperCase() : "";
395
+ const url = trackerType === "none" ? "" : await askText("Tracker URL (optional, press Enter to skip)", "");
396
+ const branchChoices = (initial) => [
397
+ { title: "Each repo's default branch", value: "default", description: "e.g. main or master" },
398
+ ...[...new Set(facts.map((f) => f.integration).filter(Boolean).concat(initial !== "default" ? [initial] : []))].map((b) => ({ title: b, value: b })),
399
+ { title: "Another branch…", value: "__other" },
400
+ ];
401
+ const pickBranch = async (message, initial) => {
402
+ const v = await askSelect(message, branchChoices(initial), initial);
403
+ return v === "__other" ? askText("Branch name", "") : v;
404
+ };
405
+ const base = await pickBranch("Which branch is new work started from?", g.base);
406
+ const pr = await pickBranch("Which branch do pull requests go into?", base);
407
+ const ex = key || "STP";
408
+ let branchName = await askSelect("How are work branches named?", [
409
+ { title: `${ex}-123${det("{ticket}", g.branchName)}`, value: "{ticket}", description: "just the ticket ID" },
410
+ { title: `feat/${ex}-123-add-login${det("{type}/{ticket}-{slug}", g.branchName)}`, value: "{type}/{ticket}-{slug}" },
411
+ { title: `feat/add-login${det("{type}/{slug}", g.branchName)}`, value: "{type}/{slug}", description: "no ticket ID" },
412
+ { title: "Custom pattern…", value: "__custom" },
413
+ ], g.branchName);
414
+ if (branchName === "__custom") branchName = await askText("Pattern using {ticket}, {slug}, {type}", g.branchName, (x) => /\{(ticket|slug)\}/.test(x) || "Include {ticket} or {slug}");
415
+ const commitStyle = await askSelect("How are commit messages written?", [
416
+ { title: `feat: add login${det("conventional", g.commitStyle)}`, value: "conventional", description: "conventional commits" },
417
+ { title: `${ex}-123: add login${det("ticket", g.commitStyle)}`, value: "ticket", description: "ticket ID first" },
418
+ { title: `Add login${det("freeform", g.commitStyle)}`, value: "freeform", description: "no fixed format" },
419
+ ], g.commitStyle);
420
+ const specsDir = await askText("Folder for specs (one sub-folder per feature)", "specs");
421
+ const afterApproval = opt("after-approval") || await askSelect("After you approve a spec…", [
422
+ { title: "Stop after each stage", value: "stop", description: "you start implement, verify and checkpoint yourself" },
423
+ { title: "Run straight through", value: "run-through", description: "implement → verify → commit in one go; stops at any failure, never pushes without asking" },
424
+ ], "stop");
347
425
 
348
426
  const config = {
349
427
  layout, tracker: { type: trackerType, key, url }, specsDir, devUrl: "", afterApproval,
@@ -480,7 +558,7 @@ try {
480
558
  else if (command === "status") status();
481
559
  else if (command === "install") {
482
560
  await install();
483
- if (!flag("no-setup") && (YES || (await ask("\nSet up this project now? (y/n)", "y")) === "y")) await setup();
561
+ if (!flag("no-setup") && (await askConfirm("Set up this project now?", true))) await setup();
484
562
  } else if (command === "setup") await setup();
485
563
  else throw new Error(`Unknown command "${command}" — use install (default), setup, doctor, or status`);
486
564
  if (["install", "setup"].includes(command)) console.log('\nNext: in your AI tool, say "investigate <ticket>" or "create a spec".');
@@ -488,5 +566,4 @@ try {
488
566
  console.error(`✗ ${e.message}`);
489
567
  process.exitCode = 1;
490
568
  } finally {
491
- rl?.close();
492
569
  }
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ // Launcher: plain CommonJS so it runs on any Node and can explain a too-old version,
3
+ // instead of the ESM entry failing with a syntax error.
4
+ const major = Number(process.versions.node.split(".")[0]);
5
+ if (major < 14) {
6
+ console.error(`✗ spec-driven-dev needs Node.js 14 or newer; this is ${process.version}.`);
7
+ console.error(" If you use nvm, run it from a folder without an old .nvmrc, or: nvm exec 20 npx @benjaminthomas/spec-driven-dev@latest");
8
+ process.exit(1);
9
+ }
10
+ import("./cli.mjs").catch((e) => { console.error(e); process.exit(1); });
package/package.json CHANGED
@@ -1,14 +1,39 @@
1
1
  {
2
2
  "name": "@benjaminthomas/spec-driven-dev",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Spec-driven AI development workflow (investigate → spec → implement → verify → ship) as Agent Skills for Claude Code, Cursor, Codex CLI and Antigravity.",
5
5
  "type": "module",
6
- "bin": { "spec-driven-dev": "bin/cli.mjs" },
7
- "files": ["bin", "skills"],
8
- "scripts": { "test": "python3 scripts/check.py && node scripts/test-cli.mjs" },
9
- "engines": { "node": ">=18" },
10
- "keywords": ["agent-skills", "claude-code", "cursor", "codex", "antigravity", "spec-driven-development", "ai-agents"],
11
- "repository": { "type": "git", "url": "git+https://github.com/benjaminthomas/spec-driven-dev.git" },
6
+ "bin": {
7
+ "spec-driven-dev": "bin/spec-driven-dev.cjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "skills"
12
+ ],
13
+ "scripts": {
14
+ "test": "python3 scripts/check.py && node scripts/test-cli.mjs"
15
+ },
16
+ "engines": {
17
+ "node": ">=14"
18
+ },
19
+ "keywords": [
20
+ "agent-skills",
21
+ "claude-code",
22
+ "cursor",
23
+ "codex",
24
+ "antigravity",
25
+ "spec-driven-development",
26
+ "ai-agents"
27
+ ],
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/benjaminthomas/spec-driven-dev.git"
31
+ },
12
32
  "license": "MIT",
13
- "publishConfig": { "access": "public" }
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "dependencies": {
37
+ "prompts": "2.4.2"
38
+ }
14
39
  }