@kudusov.takhir/ba-toolkit 2.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/ba-toolkit.js CHANGED
@@ -18,57 +18,55 @@ const PACKAGE_ROOT = path.resolve(__dirname, '..');
18
18
  const SKILLS_DIR = path.join(PACKAGE_ROOT, 'skills');
19
19
  const PKG = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8'));
20
20
 
21
- // In v2.0 the install paths dropped the previous `ba-toolkit/` wrapper
22
- // directory. Claude Code, Codex CLI, and Gemini CLI all expect skills
23
- // to be discoverable as direct subfolders of their skills root
24
- // `.claude/skills/<skill-name>/SKILL.md`, not nested one level deeper.
25
- // The wrapper made all 21 skills invisible to every agent.
21
+ // All five supported agents Claude Code, Codex CLI, Gemini CLI,
22
+ // Cursor, and Windsurf load Agent Skills as direct subfolders of
23
+ // their skills root: `<skills-root>/<skill-name>/SKILL.md`. The toolkit
24
+ // installs the 21 skills natively in this layout for every agent. No
25
+ // .mdc conversion. Confirmed against the Agent Skills documentation
26
+ // for each platform via ctx7 MCP / official docs.
26
27
  //
27
- // Cursor and Windsurf load `.mdc` rule files directly from their rules
28
- // root, so v2.0 also flattens that layout: the per-skill subfolders
29
- // produced by the previous version are gone, and rules sit at
30
- // `.cursor/rules/<skill-name>.mdc`.
28
+ // Earlier versions tried to install Cursor and Windsurf via `.mdc`
29
+ // rules under `.cursor/rules/` and `.windsurf/rules/` but Rules and
30
+ // Agent Skills are two separate features in both editors, and the
31
+ // toolkit is a pipeline of skills, not rules. The wrong-feature install
32
+ // silently failed: skills loaded as rules never surfaced as `/brief`,
33
+ // `/srs`, … slash commands. v2.x corrects this for Cursor, and the
34
+ // Windsurf cleanup in this changelog entry finishes the job.
31
35
  //
32
- // To stay safe sharing the skills root with the user's other skills /
33
- // rules, every install also drops a `.ba-toolkit-manifest.json` next to
34
- // the installed items. uninstall and upgrade read this manifest to
35
- // remove only what the toolkit owns; without it they refuse to touch
36
- // anything.
36
+ // To stay safe sharing the skills root with the user's other skills,
37
+ // every install also drops a `.ba-toolkit-manifest.json` next to the
38
+ // installed items. uninstall and upgrade read this manifest to remove
39
+ // only what the toolkit owns; without it they refuse to touch anything.
37
40
  const AGENTS = {
38
41
  'claude-code': {
39
42
  name: 'Claude Code',
40
43
  projectPath: '.claude/skills',
41
44
  globalPath: path.join(os.homedir(), '.claude', 'skills'),
42
- format: 'skill',
43
45
  restartHint: 'Restart Claude Code to load the new skills.',
44
46
  },
45
47
  codex: {
46
48
  name: 'OpenAI Codex CLI',
47
49
  projectPath: null, // Codex uses only global
48
50
  globalPath: path.join(process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), 'skills'),
49
- format: 'skill',
50
51
  restartHint: 'Restart the Codex CLI to load the new skills.',
51
52
  },
52
53
  gemini: {
53
54
  name: 'Google Gemini CLI',
54
55
  projectPath: '.gemini/skills',
55
56
  globalPath: path.join(os.homedir(), '.gemini', 'skills'),
56
- format: 'skill',
57
57
  restartHint: 'Reload Gemini CLI to pick up the new skills.',
58
58
  },
59
59
  cursor: {
60
60
  name: 'Cursor',
61
- projectPath: '.cursor/rules',
62
- globalPath: null, // Cursor rules are project-scoped
63
- format: 'mdc',
64
- restartHint: 'Reload the Cursor window to apply new rules.',
61
+ projectPath: '.cursor/skills',
62
+ globalPath: null, // Cursor skills are project-scoped for now
63
+ restartHint: 'Reload the Cursor window to apply new skills.',
65
64
  },
66
65
  windsurf: {
67
66
  name: 'Windsurf',
68
- projectPath: '.windsurf/rules',
69
- globalPath: null,
70
- format: 'mdc',
71
- restartHint: 'Reload the Windsurf window to apply new rules.',
67
+ projectPath: '.windsurf/skills',
68
+ globalPath: null, // Windsurf skills are project-scoped for now
69
+ restartHint: 'Reload the Windsurf window to apply new skills.',
72
70
  },
73
71
  };
74
72
 
@@ -85,6 +83,21 @@ const DOMAINS = [
85
83
  { id: 'custom', name: 'Custom', desc: 'Any other domain — general interview questions' },
86
84
  ];
87
85
 
86
+ // ASCII banner shown at the top of `ba-toolkit init`. Suppressed on
87
+ // non-TTY stdout so it doesn't end up in CI logs or piped output.
88
+ // Stored as an array of literal lines (not a template literal) so the
89
+ // `$` characters stay out of any interpolation path.
90
+ const BANNER = [
91
+ ' /$$ /$$ /$$ /$$ /$$ /$$ ',
92
+ '| $$ | $$ | $$| $$ |__/ | $$ ',
93
+ '| $$$$$$$ /$$$$$$ /$$$$$$ /$$$$$$ /$$$$$$ | $$| $$ /$$ /$$ /$$$$$$ ',
94
+ '| $$__ $$ |____ $$ /$$$$$$|_ $$_/ /$$__ $$ /$$__ $$| $$| $$ /$$/| $$|_ $$_/ ',
95
+ '| $$ \\ $$ /$$$$$$$|______/ | $$ | $$ \\ $$| $$ \\ $$| $$| $$$$$$/ | $$ | $$ ',
96
+ '| $$ | $$ /$$__ $$ | $$ /$$| $$ | $$| $$ | $$| $$| $$_ $$ | $$ | $$ /$$',
97
+ '| $$$$$$$/| $$$$$$$ | $$$$/| $$$$$$/| $$$$$$/| $$| $$ \\ $$| $$ | $$$$/',
98
+ '|_______/ \\_______/ \\___/ \\______/ \\______/ |__/|__/ \\__/|__/ \\___/ ',
99
+ ];
100
+
88
101
  // --- Terminal helpers --------------------------------------------------
89
102
 
90
103
  const NO_COLOR = !!process.env.NO_COLOR || !process.stdout.isTTY;
@@ -99,6 +112,17 @@ const bold = colour(1);
99
112
  function log(...args) { console.log(...args); }
100
113
  function logError(...args) { console.error(red('error:'), ...args); }
101
114
 
115
+ // Print the BANNER to stdout if — and only if — stdout is a real TTY.
116
+ // Piped / redirected runs (CI, test spawn, `ba-toolkit init | tee ...`)
117
+ // get a clean log without the 8-line block. The banner is decorative,
118
+ // not load-bearing, so suppressing it in non-interactive contexts is
119
+ // the right default.
120
+ function printBanner() {
121
+ if (!process.stdout.isTTY) return;
122
+ for (const line of BANNER) log(cyan(line));
123
+ log('');
124
+ }
125
+
102
126
  // --- Arg parsing -------------------------------------------------------
103
127
 
104
128
  function parseArgs(argv) {
@@ -140,30 +164,66 @@ function parseArgs(argv) {
140
164
  // --- Prompt helper -----------------------------------------------------
141
165
 
142
166
  // Shared across all prompts in a single CLI invocation. Creating a new
143
- // readline.Interface for every question (the previous approach) made Ctrl+C
167
+ // readline.Interface for every question (the earlier approach) made Ctrl+C
144
168
  // handling unreliable, leaked listeners on stdin, and broke when stdin was
145
- // piped (EOF on the second create). One interface per process, closed by
146
- // closeReadline() once main() finishes (or by the SIGINT handler).
169
+ // piped. One interface per process, closed by closeReadline() once main()
170
+ // finishes (or by the SIGINT handler).
171
+ //
172
+ // prompt() does NOT use `rl.question(...)` — that method races with
173
+ // readline's internal line buffering when stdin is piped. If input arrives
174
+ // faster than prompts are issued (the common piped case: the user pipes a
175
+ // here-doc with multiple answers, or a test feeds the entire stdin buffer
176
+ // upfront), readline emits 'line' events before the question listener is
177
+ // attached and those lines are silently dropped. The second prompt then
178
+ // sees EOF and errors with INPUT_CLOSED despite the answer actually being
179
+ // in the buffer.
180
+ //
181
+ // Instead we own the 'line' event ourselves and keep a line queue: every
182
+ // line that arrives is pushed onto `lineQueue` if no one is waiting, or
183
+ // delivered directly to the oldest waiter. A prompt() call takes the head
184
+ // of the queue if non-empty, otherwise parks a waiter. The 'close' event
185
+ // drains all waiting waiters with INPUT_CLOSED.
147
186
  let sharedRl = null;
187
+ const lineQueue = [];
188
+ const waiters = [];
189
+ let inputClosed = false;
190
+
191
+ function ensureReadline() {
192
+ if (sharedRl) return;
193
+ sharedRl = readline.createInterface({ input: process.stdin, output: process.stdout });
194
+ sharedRl.on('line', (line) => {
195
+ if (waiters.length > 0) {
196
+ waiters.shift().resolve(line);
197
+ } else {
198
+ lineQueue.push(line);
199
+ }
200
+ });
201
+ sharedRl.on('close', () => {
202
+ inputClosed = true;
203
+ while (waiters.length > 0) {
204
+ const err = new Error('input stream closed before answer');
205
+ err.code = 'INPUT_CLOSED';
206
+ waiters.shift().reject(err);
207
+ }
208
+ });
209
+ }
148
210
 
149
211
  function prompt(question) {
150
- if (!sharedRl) {
151
- sharedRl = readline.createInterface({ input: process.stdin, output: process.stdout });
212
+ ensureReadline();
213
+ // Render the question ourselves we're not using rl.question().
214
+ process.stdout.write(question);
215
+ if (lineQueue.length > 0) {
216
+ return Promise.resolve(String(lineQueue.shift()).trim());
217
+ }
218
+ if (inputClosed) {
219
+ const err = new Error('input stream closed before answer');
220
+ err.code = 'INPUT_CLOSED';
221
+ return Promise.reject(err);
152
222
  }
153
223
  return new Promise((resolve, reject) => {
154
- let answered = false;
155
- const onClose = () => {
156
- if (!answered) {
157
- const err = new Error('input stream closed before answer');
158
- err.code = 'INPUT_CLOSED';
159
- reject(err);
160
- }
161
- };
162
- sharedRl.once('close', onClose);
163
- sharedRl.question(question, (answer) => {
164
- answered = true;
165
- sharedRl.removeListener('close', onClose);
166
- resolve(answer.trim());
224
+ waiters.push({
225
+ resolve: (line) => resolve(String(line).trim()),
226
+ reject,
167
227
  });
168
228
  });
169
229
  }
@@ -175,6 +235,241 @@ function closeReadline() {
175
235
  }
176
236
  }
177
237
 
238
+ // --- Arrow-key menus -----------------------------------------------------
239
+ //
240
+ // Three layers, separated for testability:
241
+ //
242
+ // 1. menuStep(state, key) — pure state machine. Given the current
243
+ // menu state and a normalised key action, returns the new state.
244
+ // Unit-tested directly. No dependencies, no I/O.
245
+ //
246
+ // 2. renderMenu(state, opts) — pure renderer. Returns the frame to
247
+ // print as a string. Unit-tested too — uses the colour helpers,
248
+ // which collapse to identity strings under NO_COLOR (i.e., in
249
+ // tests), so the assertions are stable.
250
+ //
251
+ // 3. runMenuTty(items, opts) / selectMenu(items, opts) — the I/O
252
+ // glue. Detects TTY, sets raw mode, listens for keypress events,
253
+ // drives the loop, falls back to a numbered prompt under
254
+ // promptUntilValid when the terminal is non-interactive (CI,
255
+ // piped input, TERM=dumb). Not unit-tested — covered by manual
256
+ // smoke and the existing fallback-path integration tests.
257
+ //
258
+ // Cross-platform note: Node's `readline.emitKeypressEvents` decodes
259
+ // arrow-key escape sequences uniformly across bash/zsh/fish on
260
+ // Linux/macOS, Windows Terminal (PowerShell, cmd, WSL), Git Bash /
261
+ // MSYS2, and VSCode's integrated terminal. Modern Node also enables VT
262
+ // mode automatically on Windows when raw mode is requested, so legacy
263
+ // cmd.exe on Win10+ works too. The only environment we explicitly bail
264
+ // out of is `TERM=dumb` (emacs M-x shell, some IDE shells) — keypress
265
+ // decoding is unreliable there.
266
+
267
+ function menuStep(state, key) {
268
+ if (state.done) return state;
269
+ const len = state.items.length;
270
+ if (len === 0) return state;
271
+ switch (key) {
272
+ case 'up':
273
+ return { ...state, index: (state.index - 1 + len) % len };
274
+ case 'down':
275
+ return { ...state, index: (state.index + 1) % len };
276
+ case 'enter':
277
+ return { ...state, done: true, choice: state.items[state.index] };
278
+ case 'cancel':
279
+ return { ...state, done: true, choice: null };
280
+ default: {
281
+ // Letter jump: 'a' → 0, 'b' → 1, …, 'i' → 8.
282
+ if (/^[a-z]$/.test(key)) {
283
+ const idx = key.charCodeAt(0) - 'a'.charCodeAt(0);
284
+ if (idx < len) {
285
+ return { ...state, index: idx };
286
+ }
287
+ return state;
288
+ }
289
+ // Digit jump kept as a backward-compat fallback so existing
290
+ // CI scripts and muscle-memory keep working: '1' → 0, '9' → 8.
291
+ if (/^[0-9]$/.test(key)) {
292
+ const n = parseInt(key, 10);
293
+ if (n >= 1 && n <= len) {
294
+ return { ...state, index: n - 1 };
295
+ }
296
+ }
297
+ return state;
298
+ }
299
+ }
300
+ }
301
+
302
+ function renderMenu(state, { title } = {}) {
303
+ const lines = [];
304
+ if (title) {
305
+ lines.push(' ' + yellow(title));
306
+ lines.push('');
307
+ }
308
+ const labelWidth = Math.max(...state.items.map((it) => it.label.length));
309
+ state.items.forEach((item, i) => {
310
+ const selected = i === state.index;
311
+ const marker = selected ? cyan('>') : ' ';
312
+ // Letter ID — `a`, `b`, `c`, … — matches the interview-protocol
313
+ // table format and works for menus up to 26 items (we currently
314
+ // ship 10 domains and 5 agents, so this is plenty).
315
+ const id = String.fromCharCode('a'.charCodeAt(0) + i);
316
+ const label = selected ? bold(item.label.padEnd(labelWidth)) : item.label.padEnd(labelWidth);
317
+ const desc = item.desc ? ' ' + gray('— ' + item.desc) : '';
318
+ lines.push(` ${marker} ${id}) ${label}${desc}`);
319
+ });
320
+ lines.push('');
321
+ lines.push(' ' + gray('↑/↓ navigate · Enter select · a-z jump · Esc cancel'));
322
+ return lines.join('\n') + '\n';
323
+ }
324
+
325
+ // True when arrow-key menus are usable in this process. False under
326
+ // piped stdin/stdout, dumb terminals, or when raw mode is unavailable.
327
+ function isInteractiveTerminal() {
328
+ if (!process.stdin.isTTY) return false;
329
+ if (!process.stdout.isTTY) return false;
330
+ if (process.env.TERM === 'dumb') return false;
331
+ if (typeof process.stdin.setRawMode !== 'function') return false;
332
+ return true;
333
+ }
334
+
335
+ // TTY runner: drive the menu state machine via raw-mode keypress
336
+ // events. Returns the chosen item or null if the user cancelled.
337
+ // The caller is responsible for not invoking this when
338
+ // isInteractiveTerminal() is false.
339
+ function runMenuTty(items, { title } = {}) {
340
+ // The shared line-mode readline (used by `prompt()`) and a raw-mode
341
+ // keypress reader can't both own stdin at the same time. Close any
342
+ // line-mode interface before we take over; the next prompt() call
343
+ // will lazily recreate it via ensureReadline().
344
+ closeReadline();
345
+
346
+ return new Promise((resolve) => {
347
+ let state = { items, index: 0, done: false, choice: null };
348
+ let lastFrameLineCount = 0;
349
+
350
+ const render = () => {
351
+ // Erase the previous frame in place: move the cursor up over its
352
+ // line count, then clear from cursor to end of screen. First
353
+ // render has nothing to erase.
354
+ if (lastFrameLineCount > 0) {
355
+ process.stdout.write(`\x1b[${lastFrameLineCount}A\x1b[J`);
356
+ }
357
+ const frame = renderMenu(state, { title });
358
+ process.stdout.write(frame);
359
+ // Count lines actually printed (frame ends with a trailing \n).
360
+ lastFrameLineCount = frame.split('\n').length - 1;
361
+ };
362
+
363
+ const cleanup = () => {
364
+ process.stdin.removeListener('keypress', onKey);
365
+ try {
366
+ process.stdin.setRawMode(false);
367
+ } catch { /* setRawMode can throw if stdin is not a TTY anymore */ }
368
+ process.stdin.pause();
369
+ };
370
+
371
+ const onKey = (_str, key) => {
372
+ if (!key) return;
373
+ let action = null;
374
+ if (key.ctrl && key.name === 'c') action = 'cancel';
375
+ else if (key.name === 'escape') action = 'cancel';
376
+ else if (key.name === 'up' || key.name === 'k') action = 'up';
377
+ else if (key.name === 'down' || key.name === 'j') action = 'down';
378
+ else if (key.name === 'return') action = 'enter';
379
+ // Letter jump (a-z) is the new primary; digit (0-9) stays as a
380
+ // backward-compat fallback for users who muscle-memory cipher
381
+ // navigation. menuStep parses both — see its switch statement.
382
+ // Note: 'j' and 'k' are intercepted above as down/up (vim-bindings),
383
+ // so they never reach the letter-jump path.
384
+ else if (key.sequence && /^[a-z]$/.test(key.sequence)) action = key.sequence;
385
+ else if (key.sequence && /^[0-9]$/.test(key.sequence)) action = key.sequence;
386
+ if (!action) return;
387
+ state = menuStep(state, action);
388
+ if (state.done) {
389
+ cleanup();
390
+ resolve(state.choice);
391
+ } else {
392
+ render();
393
+ }
394
+ };
395
+
396
+ readline.emitKeypressEvents(process.stdin);
397
+ process.stdin.setRawMode(true);
398
+ process.stdin.resume();
399
+ process.stdin.on('keypress', onKey);
400
+ render();
401
+ });
402
+ }
403
+
404
+ // Top-level selector: interactive arrow-key menu in real terminals,
405
+ // numbered prompt fallback everywhere else (CI, piped input, dumb
406
+ // TERM, EditorIDE shells). Always returns either an item from `items`
407
+ // or null on cancel.
408
+ async function selectMenu(items, { title, fallbackPrompt }) {
409
+ if (isInteractiveTerminal()) {
410
+ return await runMenuTty(items, { title });
411
+ }
412
+ // Non-TTY fallback: print the lettered list once, then prompt with
413
+ // promptUntilValid so a single typo doesn't kill the wizard.
414
+ log('');
415
+ if (title) log(' ' + yellow(title));
416
+ const labelWidth = Math.max(...items.map((it) => it.label.length));
417
+ items.forEach((item, i) => {
418
+ const id = String.fromCharCode('a'.charCodeAt(0) + i);
419
+ const desc = item.desc ? ' ' + gray('— ' + item.desc) : '';
420
+ log(` ${id}) ${bold(item.label.padEnd(labelWidth))}${desc}`);
421
+ });
422
+ log('');
423
+ return await promptUntilValid(
424
+ fallbackPrompt,
425
+ (raw) => {
426
+ const trimmed = String(raw || '').toLowerCase().trim();
427
+ if (!trimmed) return null;
428
+ // Letter ID is the primary input (a → 0, b → 1, …).
429
+ if (/^[a-z]$/.test(trimmed)) {
430
+ const idx = trimmed.charCodeAt(0) - 'a'.charCodeAt(0);
431
+ return idx < items.length ? items[idx] : null;
432
+ }
433
+ // Digit ID stays as a fallback so legacy CI scripts and pasted
434
+ // numbers still work (1 → 0, 2 → 1, …).
435
+ if (/^\d+$/.test(trimmed)) {
436
+ const n = parseInt(trimmed, 10);
437
+ return n >= 1 && n <= items.length ? items[n - 1] : null;
438
+ }
439
+ // Verbatim id-string fallback (e.g., 'saas', 'claude-code').
440
+ return items.find((it) => it.id === trimmed) || null;
441
+ },
442
+ { invalidMessage: `Invalid selection — pick a letter (a–${String.fromCharCode('a'.charCodeAt(0) + items.length - 1)}), a number, or an id.` },
443
+ );
444
+ }
445
+
446
+ // Ask the user `question`, run `resolver` on the trimmed answer, and
447
+ // loop while the resolver returns null/undefined. Prints a yellow
448
+ // "try again" message between attempts. Aborts with process.exit(1)
449
+ // after `maxAttempts` consecutive invalid answers so a piped input
450
+ // can't infinite-loop us.
451
+ //
452
+ // Previously, cmdInit called `resolveDomain` / `resolveAgent` /
453
+ // `sanitiseSlug` once and hard-failed on the first typo — users who
454
+ // mistyped "saass" lost the whole wizard and had to start over. With
455
+ // the retry loop, they just read the error and try again.
456
+ async function promptUntilValid(question, resolver, {
457
+ maxAttempts = 3,
458
+ invalidMessage = 'Invalid selection — try again.',
459
+ } = {}) {
460
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
461
+ const raw = await prompt(question);
462
+ const result = resolver(raw);
463
+ if (result != null && result !== '') return result;
464
+ const remaining = maxAttempts - attempt;
465
+ if (remaining > 0) {
466
+ log(' ' + yellow(`${invalidMessage} (${remaining} attempt${remaining === 1 ? '' : 's'} left)`));
467
+ }
468
+ }
469
+ logError(`Too many invalid attempts — aborting.`);
470
+ process.exit(1);
471
+ }
472
+
178
473
  // --- Utilities ---------------------------------------------------------
179
474
 
180
475
  function sanitiseSlug(input) {
@@ -352,8 +647,10 @@ function copyDirRecursive(src, dest, { dryRun, copied }) {
352
647
  // - Quoted scalars (single or double quoted) — names would keep quotes
353
648
  //
354
649
  // Returns { name, description, body }. `description` is always
355
- // flattened to a single line (whitespace collapsed) because the .mdc
356
- // rule format expects a one-line description.
650
+ // flattened to a single line (whitespace collapsed) keeps the
651
+ // downstream consumers (manifest summary, status output, agent skill
652
+ // loaders that expect a single-line description) free of multi-line
653
+ // surprises.
357
654
  function parseSkillFrontmatter(content) {
358
655
  const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
359
656
  if (!fmMatch) {
@@ -402,34 +699,18 @@ function parseSkillFrontmatter(content) {
402
699
  };
403
700
  }
404
701
 
405
- // Transform a SKILL.md file's contents to the Cursor/Windsurf .mdc rule
406
- // format: replace the YAML frontmatter with the two fields the rule
407
- // loader expects (description, alwaysApply), keep the body unchanged.
408
- function skillToMdcContent(content) {
409
- const { description, body } = parseSkillFrontmatter(content);
410
- return `---\ndescription: ${description}\nalwaysApply: false\n---\n\n` + body;
411
- }
412
-
413
- // Install the package's skills/ tree into the given destination, picking
414
- // the layout the target agent expects.
415
- //
416
- // For 'skill' format (Claude Code, Codex, Gemini): each source skill
417
- // folder lands as `<destRoot>/<skillName>/SKILL.md`. The references/
702
+ // Install the package's skills/ tree into the given destination. Every
703
+ // supported agent uses the same Agent Skills layout: each source skill
704
+ // folder lands as `<destRoot>/<skillName>/SKILL.md`. The `references/`
418
705
  // folder is copied as-is to `<destRoot>/references/`.
419
706
  //
420
- // For 'mdc' format (Cursor, Windsurf): each source skill folder is
421
- // flattened to a single `<destRoot>/<skillName>.mdc` file containing
422
- // the transformed content. References still go to `<destRoot>/references/`
423
- // — non-.mdc files there are ignored by the rule loaders, but the LLM
424
- // can still find them at runtime via the Read tool.
425
- //
426
707
  // Skill names come from the SKILL.md `name:` frontmatter field, falling
427
708
  // back to the source folder name. Returns:
428
709
  // { copied, items }
429
710
  // where `copied` is the list of absolute file paths written and `items`
430
711
  // is the list of top-level entries in destRoot that the toolkit owns
431
712
  // (used to write the manifest).
432
- function copySkills(srcRoot, destRoot, { format, dryRun = false }) {
713
+ function copySkills(srcRoot, destRoot, { dryRun = false } = {}) {
433
714
  if (!fs.existsSync(srcRoot)) {
434
715
  throw new Error(`Source directory not found: ${srcRoot}`);
435
716
  }
@@ -456,17 +737,9 @@ function copySkills(srcRoot, destRoot, { format, dryRun = false }) {
456
737
  const { name } = parseSkillFrontmatter(content);
457
738
  const skillName = name || entry.name;
458
739
 
459
- if (format === 'mdc') {
460
- const transformed = skillToMdcContent(content);
461
- const destFile = path.join(destRoot, `${skillName}.mdc`);
462
- if (!dryRun) fs.writeFileSync(destFile, transformed);
463
- copied.push(destFile);
464
- items.push(`${skillName}.mdc`);
465
- } else {
466
- const skillDestDir = path.join(destRoot, skillName);
467
- copyDirRecursive(srcPath, skillDestDir, { dryRun, copied });
468
- items.push(skillName);
469
- }
740
+ const skillDestDir = path.join(destRoot, skillName);
741
+ copyDirRecursive(srcPath, skillDestDir, { dryRun, copied });
742
+ items.push(skillName);
470
743
  }
471
744
 
472
745
  return { copied, items };
@@ -486,6 +759,13 @@ function copySkills(srcRoot, destRoot, { format, dryRun = false }) {
486
759
  // 21-skill list and ordering; keep that template in sync with it.
487
760
  const AGENTS_TEMPLATE_PATH = path.join(SKILLS_DIR, 'references', 'templates', 'agents-template.md');
488
761
 
762
+ // Anchor markers delimit the block inside AGENTS.md that `ba-toolkit
763
+ // init` owns and is allowed to rewrite on re-init. Everything outside
764
+ // the anchors (Pipeline Status, Key Constraints, Open Questions, user
765
+ // notes) is preserved untouched. See agents-template.md.
766
+ const AGENTS_MANAGED_BEGIN = '<!-- ba-toolkit:begin managed -->';
767
+ const AGENTS_MANAGED_END = '<!-- ba-toolkit:end managed -->';
768
+
489
769
  function renderAgentsMd({ name, slug, domain }) {
490
770
  let template;
491
771
  try {
@@ -500,9 +780,50 @@ function renderAgentsMd({ name, slug, domain }) {
500
780
  .replace(/\[DATE\]/g, today());
501
781
  }
502
782
 
783
+ // Merge the fresh AGENTS.md content into whatever already exists at
784
+ // the project root. Three branches:
785
+ //
786
+ // 1. No existing file (existing == null) — return the fresh template,
787
+ // action 'created'.
788
+ // 2. Existing file has both anchor markers — replace only the managed
789
+ // block content between the anchors, leave the rest of the file
790
+ // (Pipeline Status, Key Constraints, user notes) untouched. Action
791
+ // 'merged'.
792
+ // 3. Existing file has no anchors — it's either a legacy AGENTS.md
793
+ // from a pre-merge version of the toolkit or a fully user-authored
794
+ // file. Leave it untouched and return { action: 'preserved' } so
795
+ // the caller can print a note. We never silently overwrite
796
+ // user content.
797
+ //
798
+ // Pure function for easy testing. Exported so test/cli.test.js can
799
+ // cover all three branches without spawning a process.
800
+ function mergeAgentsMd(existing, ctx) {
801
+ const fresh = renderAgentsMd(ctx);
802
+ if (existing == null) {
803
+ return { content: fresh, action: 'created' };
804
+ }
805
+ const beginIdx = existing.indexOf(AGENTS_MANAGED_BEGIN);
806
+ const endIdx = existing.indexOf(AGENTS_MANAGED_END);
807
+ if (beginIdx === -1 || endIdx === -1 || endIdx < beginIdx) {
808
+ return { content: existing, action: 'preserved' };
809
+ }
810
+ const freshBeginIdx = fresh.indexOf(AGENTS_MANAGED_BEGIN);
811
+ const freshEndIdx = fresh.indexOf(AGENTS_MANAGED_END);
812
+ if (freshBeginIdx === -1 || freshEndIdx === -1) {
813
+ // Template is broken — fall back to returning fresh. Should be
814
+ // caught in unit tests if the template file ever loses its anchors.
815
+ return { content: fresh, action: 'created' };
816
+ }
817
+ const freshManaged = fresh.slice(freshBeginIdx, freshEndIdx + AGENTS_MANAGED_END.length);
818
+ const before = existing.slice(0, beginIdx);
819
+ const after = existing.slice(endIdx + AGENTS_MANAGED_END.length);
820
+ return { content: before + freshManaged + after, action: 'merged' };
821
+ }
822
+
503
823
  // --- Commands ----------------------------------------------------------
504
824
 
505
825
  async function cmdInit(args) {
826
+ printBanner();
506
827
  log('');
507
828
  log(' ' + cyan('BA Toolkit — New Project Setup'));
508
829
  log(' ' + cyan('================================'));
@@ -537,20 +858,43 @@ async function cmdInit(args) {
537
858
  }
538
859
  slug = derived;
539
860
  } else if (derived) {
540
- const custom = await prompt(` Project slug [${cyan(derived)}]: `);
541
- slug = custom || derived;
861
+ // Default branch: the derived slug is offered as the suggested
862
+ // answer. Empty input accepts the suggestion; anything the user
863
+ // types is run through sanitiseSlug and must produce something
864
+ // non-empty — otherwise re-prompt.
865
+ slug = await promptUntilValid(
866
+ ` Project slug [${cyan(derived)}]: `,
867
+ (raw) => {
868
+ const typed = String(raw || '').trim();
869
+ if (!typed) return derived;
870
+ const cleaned = sanitiseSlug(typed);
871
+ return cleaned || null;
872
+ },
873
+ { invalidMessage: 'Invalid slug — must produce at least one ASCII letter/digit after sanitisation.' },
874
+ );
542
875
  } else {
543
876
  log(' ' + gray(`(could not derive a slug from "${name}" — please type one manually)`));
544
- slug = await prompt(' Project slug (lowercase, hyphens only): ');
877
+ slug = await promptUntilValid(
878
+ ' Project slug (lowercase, hyphens only): ',
879
+ (raw) => {
880
+ const cleaned = sanitiseSlug(String(raw || '').trim());
881
+ return cleaned || null;
882
+ },
883
+ { invalidMessage: 'Invalid slug — must contain at least one ASCII letter or digit.' },
884
+ );
545
885
  }
546
886
  }
887
+ // At this point `slug` is already a sanitised, non-empty string from
888
+ // one of the branches above. The final sanitiseSlug call is a
889
+ // defensive no-op for the flag path (--slug) where we haven't
890
+ // cleaned it yet.
547
891
  slug = sanitiseSlug(slug);
548
892
  if (!slug) {
549
893
  logError('Invalid or empty slug.');
550
894
  process.exit(1);
551
895
  }
552
896
 
553
- // --- 3. Domain (numbered menu) ---
897
+ // --- 3. Domain (arrow menu in TTY, numbered fallback elsewhere) ---
554
898
  const domainFlag = stringFlag(args, 'domain');
555
899
  let domain;
556
900
  if (domainFlag) {
@@ -561,20 +905,18 @@ async function cmdInit(args) {
561
905
  process.exit(1);
562
906
  }
563
907
  } else {
564
- log('');
565
- log(' ' + yellow('Pick a domain:'));
566
- const domainNameWidth = Math.max(...DOMAINS.map((d) => d.name.length));
567
- DOMAINS.forEach((d, i) => {
568
- const idx = String(i + 1).padStart(2);
569
- log(` ${idx}) ${bold(d.name.padEnd(domainNameWidth))} ${gray('— ' + d.desc)}`);
570
- });
571
- log('');
572
- const raw = await prompt(` Select [1-${DOMAINS.length}]: `);
573
- domain = resolveDomain(raw);
574
- if (!domain) {
575
- logError(`Invalid selection: ${raw || '(empty)'}`);
576
- process.exit(1);
908
+ const chosen = await selectMenu(
909
+ DOMAINS.map((d) => ({ id: d.id, label: d.name, desc: d.desc })),
910
+ {
911
+ title: 'Pick a domain:',
912
+ fallbackPrompt: ` Select [a-${String.fromCharCode('a'.charCodeAt(0) + DOMAINS.length - 1)}]: `,
913
+ },
914
+ );
915
+ if (chosen == null) {
916
+ log(' ' + yellow('Cancelled.'));
917
+ process.exit(130);
577
918
  }
919
+ domain = chosen.id;
578
920
  }
579
921
 
580
922
  // --- 4. Agent (numbered menu), unless --no-install ---
@@ -590,21 +932,19 @@ async function cmdInit(args) {
590
932
  process.exit(1);
591
933
  }
592
934
  } else {
593
- log('');
594
- log(' ' + yellow('Pick your AI agent:'));
595
935
  const agentEntries = Object.entries(AGENTS);
596
- const agentNameWidth = Math.max(...agentEntries.map(([, a]) => a.name.length));
597
- agentEntries.forEach(([id, a], i) => {
598
- const idx = String(i + 1).padStart(2);
599
- log(` ${idx}) ${bold(a.name.padEnd(agentNameWidth))} ${gray('(' + id + ')')}`);
600
- });
601
- log('');
602
- const raw = await prompt(` Select [1-${agentEntries.length}]: `);
603
- agentId = resolveAgent(raw);
604
- if (!agentId) {
605
- logError(`Invalid selection: ${raw || '(empty)'}`);
606
- process.exit(1);
936
+ const chosen = await selectMenu(
937
+ agentEntries.map(([id, a]) => ({ id, label: a.name, desc: '(' + id + ')' })),
938
+ {
939
+ title: 'Pick your AI agent:',
940
+ fallbackPrompt: ` Select [a-${String.fromCharCode('a'.charCodeAt(0) + agentEntries.length - 1)}]: `,
941
+ },
942
+ );
943
+ if (chosen == null) {
944
+ log(' ' + yellow('Cancelled.'));
945
+ process.exit(130);
607
946
  }
947
+ agentId = chosen.id;
608
948
  }
609
949
  }
610
950
 
@@ -620,18 +960,26 @@ async function cmdInit(args) {
620
960
  log(` exists ${outputDir}`);
621
961
  }
622
962
 
623
- const agentsPath = 'AGENTS.md';
624
- let writeAgents = true;
625
- if (fs.existsSync(agentsPath)) {
626
- const answer = await prompt(' AGENTS.md already exists. Overwrite? (y/N): ');
627
- if (answer.toLowerCase() !== 'y') {
628
- writeAgents = false;
629
- log(' skipped AGENTS.md');
630
- }
631
- }
632
- if (writeAgents) {
633
- fs.writeFileSync(agentsPath, renderAgentsMd({ name, slug, domain }));
634
- log(' created AGENTS.md');
963
+ // AGENTS.md: per-project, lives inside output/<slug>/. Two agent
964
+ // windows can now work on two different projects in the same repo
965
+ // without colliding — each cd-s into its own output/<slug>/ folder
966
+ // and finds its own AGENTS.md there. The merge-on-reinit behaviour
967
+ // (managed-block anchors) still applies, just at per-project scope.
968
+ // See mergeAgentsMd for the three branches (created, merged,
969
+ // preserved).
970
+ const agentsPath = path.join(outputDir, 'AGENTS.md');
971
+ const existingAgents = fs.existsSync(agentsPath)
972
+ ? fs.readFileSync(agentsPath, 'utf8')
973
+ : null;
974
+ const { content: agentsContent, action: agentsAction } = mergeAgentsMd(
975
+ existingAgents,
976
+ { name, slug, domain },
977
+ );
978
+ if (agentsAction === 'preserved') {
979
+ log(' ' + gray(`preserved ${agentsPath} (no ba-toolkit managed block — left untouched)`));
980
+ } else {
981
+ fs.writeFileSync(agentsPath, agentsContent);
982
+ log(` ${agentsAction === 'merged' ? 'updated ' : 'created '} ${agentsPath}`);
635
983
  }
636
984
 
637
985
  // --- 6. Install skills for the selected agent ---
@@ -652,28 +1000,31 @@ async function cmdInit(args) {
652
1000
 
653
1001
  // --- 7. Final message ---
654
1002
  log('');
655
- log(' ' + cyan(`Project '${name}' (${slug}) is ready.`));
1003
+ log(' ' + cyan(`Project '${name}' (${slug}) is ready in ${outputDir}/.`));
656
1004
  log('');
657
1005
  log(' ' + yellow('Next steps:'));
658
1006
  if (installed === true) {
659
1007
  log(' 1. ' + AGENTS[agentId].restartHint);
660
- log(' 2. Optional: run /principles to define project-wide conventions');
661
- log(' 3. Run /brief to start the BA pipeline');
1008
+ log(' 2. ' + bold(`cd ${outputDir}`) + ' — open your AI agent in this folder.');
1009
+ log(' Each project has its own AGENTS.md, so two agent windows');
1010
+ log(' can work on two different projects in the same repo.');
1011
+ log(' 3. Optional: run /principles to define project-wide conventions');
1012
+ log(' 4. Run /brief to start the BA pipeline');
662
1013
  } else if (installed === false) {
663
1014
  log(' 1. Skill install was cancelled. To install later, run:');
664
1015
  log(' ' + gray(`ba-toolkit install --for ${agentId}`));
665
- log(' 2. Open your AI assistant (Claude, Cursor, etc.)');
1016
+ log(' 2. ' + bold(`cd ${outputDir}`) + ' and open your AI agent there.');
666
1017
  log(' 3. Optional: run /principles to define project-wide conventions');
667
1018
  log(' 4. Run /brief to start the BA pipeline');
668
1019
  } else {
669
1020
  log(' 1. Install skills for your agent:');
670
1021
  log(' ' + gray('ba-toolkit install --for claude-code'));
671
- log(' 2. Open your AI assistant (Claude, Cursor, etc.)');
1022
+ log(' 2. ' + bold(`cd ${outputDir}`) + ' and open your AI agent there.');
672
1023
  log(' 3. Optional: run /principles to define project-wide conventions');
673
1024
  log(' 4. Run /brief to start the BA pipeline');
674
1025
  }
675
1026
  log('');
676
- log(' ' + gray(`Artifacts will be saved to: ${outputDir}/`));
1027
+ log(' ' + gray(`Artifacts and AGENTS.md live in: ${outputDir}/`));
677
1028
  log('');
678
1029
  }
679
1030
 
@@ -683,8 +1034,8 @@ async function cmdInit(args) {
683
1034
  // only what we own without touching the user's other skills sitting in
684
1035
  // the same directory.
685
1036
  //
686
- // Hidden filename with no `.md` / `.mdc` extension so the skill loader
687
- // of every supported agent ignores it.
1037
+ // Hidden filename with no `.md` extension so the skill loader of every
1038
+ // supported agent ignores it.
688
1039
  const MANIFEST_FILENAME = '.ba-toolkit-manifest.json';
689
1040
 
690
1041
  function readManifest(destDir) {
@@ -697,11 +1048,10 @@ function readManifest(destDir) {
697
1048
  }
698
1049
  }
699
1050
 
700
- function writeManifest(destDir, format, items) {
1051
+ function writeManifest(destDir, items) {
701
1052
  const payload = {
702
1053
  version: PKG.version,
703
1054
  installedAt: new Date().toISOString(),
704
- format,
705
1055
  items,
706
1056
  };
707
1057
  fs.writeFileSync(
@@ -755,7 +1105,7 @@ async function runInstall({ agentId, isGlobal, isProject, dryRun, showHeader = t
755
1105
  log(` source: ${SKILLS_DIR}`);
756
1106
  log(` destination: ${destDir}`);
757
1107
  log(` scope: ${effectiveGlobal ? 'global (user-wide)' : 'project-level'}`);
758
- log(` format: ${agent.format === 'mdc' ? '.mdc (converted from SKILL.md)' : 'SKILL.md (native)'}`);
1108
+ log(` format: SKILL.md (native)`);
759
1109
  if (dryRun) log(' ' + yellow('mode: dry-run (no files will be written)'));
760
1110
 
761
1111
  // Warn about a v1.x wrapper folder if one is sitting in the same
@@ -784,29 +1134,25 @@ async function runInstall({ agentId, isGlobal, isProject, dryRun, showHeader = t
784
1134
 
785
1135
  let result;
786
1136
  try {
787
- result = copySkills(SKILLS_DIR, destDir, { format: agent.format, dryRun });
1137
+ result = copySkills(SKILLS_DIR, destDir, { dryRun });
788
1138
  } catch (err) {
789
1139
  logError(err.message);
790
1140
  process.exit(1);
791
1141
  }
792
1142
 
793
1143
  if (!dryRun) {
794
- writeManifest(destDir, agent.format, result.items);
1144
+ writeManifest(destDir, result.items);
795
1145
  }
796
1146
 
797
1147
  log(' ' + green(`${dryRun ? 'would copy' : 'copied'} ${result.copied.length} files (${result.items.length} items).`));
798
- if (!dryRun && agent.format === 'mdc') {
799
- log(' ' + gray('SKILL.md files converted to .mdc rule format.'));
800
- }
801
1148
  return true;
802
1149
  }
803
1150
 
804
1151
  // Remove every item listed in the given manifest from destDir, then
805
- // remove the manifest file itself. Items are paths relative to destDir
806
- // for 'skill' format they're folder names (`brief`, `srs`, ...,
807
- // `references`), for 'mdc' they're file names (`brief.mdc`, ...,
808
- // plus the `references` folder). Anything not in the manifest is left
809
- // alone, including the user's other skills/rules in the same directory.
1152
+ // remove the manifest file itself. Items are top-level entries
1153
+ // relative to destDir folder names like `brief`, `srs`, …,
1154
+ // `references`. Anything not in the manifest is left alone, including
1155
+ // the user's other skills sitting in the same directory.
810
1156
  function removeManifestItems(destDir, manifest) {
811
1157
  for (const item of manifest.items) {
812
1158
  const p = path.join(destDir, item);
@@ -1264,10 +1610,12 @@ module.exports = {
1264
1610
  levenshtein,
1265
1611
  closestMatch,
1266
1612
  parseSkillFrontmatter,
1267
- skillToMdcContent,
1268
1613
  readManifest,
1269
1614
  detectLegacyInstall,
1270
1615
  renderAgentsMd,
1616
+ mergeAgentsMd,
1617
+ menuStep,
1618
+ renderMenu,
1271
1619
  KNOWN_FLAGS,
1272
1620
  DOMAINS,
1273
1621
  AGENTS,