@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 +30 -11
- package/bin/cli.mjs +135 -58
- package/bin/spec-driven-dev.cjs +10 -0
- package/package.json +33 -8
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
|
|
120
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
|
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/
|
|
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
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if (
|
|
63
|
-
return
|
|
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
|
-
|
|
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") || (
|
|
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
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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(/^~(
|
|
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(
|
|
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)
|
|
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
|
-
|
|
254
|
-
picked = (await
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
const
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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") && (
|
|
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.
|
|
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": {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
"
|
|
10
|
-
|
|
11
|
-
|
|
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": {
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"prompts": "2.4.2"
|
|
38
|
+
}
|
|
14
39
|
}
|