@kudusov.takhir/ba-toolkit 1.5.0 → 3.0.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,41 +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
+ // 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.
27
+ //
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.
35
+ //
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.
21
40
  const AGENTS = {
22
41
  'claude-code': {
23
42
  name: 'Claude Code',
24
- projectPath: '.claude/skills/ba-toolkit',
25
- globalPath: path.join(os.homedir(), '.claude', 'skills', 'ba-toolkit'),
26
- format: 'skill',
43
+ projectPath: '.claude/skills',
44
+ globalPath: path.join(os.homedir(), '.claude', 'skills'),
27
45
  restartHint: 'Restart Claude Code to load the new skills.',
28
46
  },
29
47
  codex: {
30
48
  name: 'OpenAI Codex CLI',
31
49
  projectPath: null, // Codex uses only global
32
- globalPath: path.join(process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), 'skills', 'ba-toolkit'),
33
- format: 'skill',
50
+ globalPath: path.join(process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), 'skills'),
34
51
  restartHint: 'Restart the Codex CLI to load the new skills.',
35
52
  },
36
53
  gemini: {
37
54
  name: 'Google Gemini CLI',
38
- projectPath: '.gemini/skills/ba-toolkit',
39
- globalPath: path.join(os.homedir(), '.gemini', 'skills', 'ba-toolkit'),
40
- format: 'skill',
55
+ projectPath: '.gemini/skills',
56
+ globalPath: path.join(os.homedir(), '.gemini', 'skills'),
41
57
  restartHint: 'Reload Gemini CLI to pick up the new skills.',
42
58
  },
43
59
  cursor: {
44
60
  name: 'Cursor',
45
- projectPath: '.cursor/rules/ba-toolkit',
46
- globalPath: null, // Cursor rules are project-scoped
47
- format: 'mdc',
48
- 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.',
49
64
  },
50
65
  windsurf: {
51
66
  name: 'Windsurf',
52
- projectPath: '.windsurf/rules/ba-toolkit',
53
- globalPath: null,
54
- format: 'mdc',
55
- 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.',
56
70
  },
57
71
  };
58
72
 
@@ -69,6 +83,21 @@ const DOMAINS = [
69
83
  { id: 'custom', name: 'Custom', desc: 'Any other domain — general interview questions' },
70
84
  ];
71
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
+
72
101
  // --- Terminal helpers --------------------------------------------------
73
102
 
74
103
  const NO_COLOR = !!process.env.NO_COLOR || !process.stdout.isTTY;
@@ -83,6 +112,17 @@ const bold = colour(1);
83
112
  function log(...args) { console.log(...args); }
84
113
  function logError(...args) { console.error(red('error:'), ...args); }
85
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
+
86
126
  // --- Arg parsing -------------------------------------------------------
87
127
 
88
128
  function parseArgs(argv) {
@@ -124,30 +164,66 @@ function parseArgs(argv) {
124
164
  // --- Prompt helper -----------------------------------------------------
125
165
 
126
166
  // Shared across all prompts in a single CLI invocation. Creating a new
127
- // readline.Interface for every question (the previous approach) made Ctrl+C
167
+ // readline.Interface for every question (the earlier approach) made Ctrl+C
128
168
  // handling unreliable, leaked listeners on stdin, and broke when stdin was
129
- // piped (EOF on the second create). One interface per process, closed by
130
- // 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.
131
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
+ }
132
210
 
133
211
  function prompt(question) {
134
- if (!sharedRl) {
135
- 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);
136
222
  }
137
223
  return new Promise((resolve, reject) => {
138
- let answered = false;
139
- const onClose = () => {
140
- if (!answered) {
141
- const err = new Error('input stream closed before answer');
142
- err.code = 'INPUT_CLOSED';
143
- reject(err);
144
- }
145
- };
146
- sharedRl.once('close', onClose);
147
- sharedRl.question(question, (answer) => {
148
- answered = true;
149
- sharedRl.removeListener('close', onClose);
150
- resolve(answer.trim());
224
+ waiters.push({
225
+ resolve: (line) => resolve(String(line).trim()),
226
+ reject,
151
227
  });
152
228
  });
153
229
  }
@@ -159,6 +235,213 @@ function closeReadline() {
159
235
  }
160
236
  }
161
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
+ if (/^[0-9]$/.test(key)) {
282
+ const n = parseInt(key, 10);
283
+ if (n >= 1 && n <= len) {
284
+ return { ...state, index: n - 1 };
285
+ }
286
+ }
287
+ return state;
288
+ }
289
+ }
290
+
291
+ function renderMenu(state, { title } = {}) {
292
+ const lines = [];
293
+ if (title) {
294
+ lines.push(' ' + yellow(title));
295
+ lines.push('');
296
+ }
297
+ const labelWidth = Math.max(...state.items.map((it) => it.label.length));
298
+ state.items.forEach((item, i) => {
299
+ const selected = i === state.index;
300
+ const marker = selected ? cyan('>') : ' ';
301
+ const idx = String(i + 1).padStart(2);
302
+ const label = selected ? bold(item.label.padEnd(labelWidth)) : item.label.padEnd(labelWidth);
303
+ const desc = item.desc ? ' ' + gray('— ' + item.desc) : '';
304
+ lines.push(` ${marker} ${idx}) ${label}${desc}`);
305
+ });
306
+ lines.push('');
307
+ lines.push(' ' + gray('↑/↓ navigate · Enter select · 1-9 jump · Esc cancel'));
308
+ return lines.join('\n') + '\n';
309
+ }
310
+
311
+ // True when arrow-key menus are usable in this process. False under
312
+ // piped stdin/stdout, dumb terminals, or when raw mode is unavailable.
313
+ function isInteractiveTerminal() {
314
+ if (!process.stdin.isTTY) return false;
315
+ if (!process.stdout.isTTY) return false;
316
+ if (process.env.TERM === 'dumb') return false;
317
+ if (typeof process.stdin.setRawMode !== 'function') return false;
318
+ return true;
319
+ }
320
+
321
+ // TTY runner: drive the menu state machine via raw-mode keypress
322
+ // events. Returns the chosen item or null if the user cancelled.
323
+ // The caller is responsible for not invoking this when
324
+ // isInteractiveTerminal() is false.
325
+ function runMenuTty(items, { title } = {}) {
326
+ // The shared line-mode readline (used by `prompt()`) and a raw-mode
327
+ // keypress reader can't both own stdin at the same time. Close any
328
+ // line-mode interface before we take over; the next prompt() call
329
+ // will lazily recreate it via ensureReadline().
330
+ closeReadline();
331
+
332
+ return new Promise((resolve) => {
333
+ let state = { items, index: 0, done: false, choice: null };
334
+ let lastFrameLineCount = 0;
335
+
336
+ const render = () => {
337
+ // Erase the previous frame in place: move the cursor up over its
338
+ // line count, then clear from cursor to end of screen. First
339
+ // render has nothing to erase.
340
+ if (lastFrameLineCount > 0) {
341
+ process.stdout.write(`\x1b[${lastFrameLineCount}A\x1b[J`);
342
+ }
343
+ const frame = renderMenu(state, { title });
344
+ process.stdout.write(frame);
345
+ // Count lines actually printed (frame ends with a trailing \n).
346
+ lastFrameLineCount = frame.split('\n').length - 1;
347
+ };
348
+
349
+ const cleanup = () => {
350
+ process.stdin.removeListener('keypress', onKey);
351
+ try {
352
+ process.stdin.setRawMode(false);
353
+ } catch { /* setRawMode can throw if stdin is not a TTY anymore */ }
354
+ process.stdin.pause();
355
+ };
356
+
357
+ const onKey = (_str, key) => {
358
+ if (!key) return;
359
+ let action = null;
360
+ if (key.ctrl && key.name === 'c') action = 'cancel';
361
+ else if (key.name === 'escape') action = 'cancel';
362
+ else if (key.name === 'up' || key.name === 'k') action = 'up';
363
+ else if (key.name === 'down' || key.name === 'j') action = 'down';
364
+ else if (key.name === 'return') action = 'enter';
365
+ else if (key.sequence && /^[0-9]$/.test(key.sequence)) action = key.sequence;
366
+ if (!action) return;
367
+ state = menuStep(state, action);
368
+ if (state.done) {
369
+ cleanup();
370
+ resolve(state.choice);
371
+ } else {
372
+ render();
373
+ }
374
+ };
375
+
376
+ readline.emitKeypressEvents(process.stdin);
377
+ process.stdin.setRawMode(true);
378
+ process.stdin.resume();
379
+ process.stdin.on('keypress', onKey);
380
+ render();
381
+ });
382
+ }
383
+
384
+ // Top-level selector: interactive arrow-key menu in real terminals,
385
+ // numbered prompt fallback everywhere else (CI, piped input, dumb
386
+ // TERM, EditorIDE shells). Always returns either an item from `items`
387
+ // or null on cancel.
388
+ async function selectMenu(items, { title, fallbackPrompt }) {
389
+ if (isInteractiveTerminal()) {
390
+ return await runMenuTty(items, { title });
391
+ }
392
+ // Non-TTY fallback: print the numbered list once, then prompt with
393
+ // promptUntilValid so a single typo doesn't kill the wizard.
394
+ log('');
395
+ if (title) log(' ' + yellow(title));
396
+ const labelWidth = Math.max(...items.map((it) => it.label.length));
397
+ items.forEach((item, i) => {
398
+ const idx = String(i + 1).padStart(2);
399
+ const desc = item.desc ? ' ' + gray('— ' + item.desc) : '';
400
+ log(` ${idx}) ${bold(item.label.padEnd(labelWidth))}${desc}`);
401
+ });
402
+ log('');
403
+ return await promptUntilValid(
404
+ fallbackPrompt,
405
+ (raw) => {
406
+ const trimmed = String(raw || '').toLowerCase().trim();
407
+ if (!trimmed) return null;
408
+ if (/^\d+$/.test(trimmed)) {
409
+ const n = parseInt(trimmed, 10);
410
+ return n >= 1 && n <= items.length ? items[n - 1] : null;
411
+ }
412
+ return items.find((it) => it.id === trimmed) || null;
413
+ },
414
+ { invalidMessage: `Invalid selection — pick a number between 1 and ${items.length} or an id.` },
415
+ );
416
+ }
417
+
418
+ // Ask the user `question`, run `resolver` on the trimmed answer, and
419
+ // loop while the resolver returns null/undefined. Prints a yellow
420
+ // "try again" message between attempts. Aborts with process.exit(1)
421
+ // after `maxAttempts` consecutive invalid answers so a piped input
422
+ // can't infinite-loop us.
423
+ //
424
+ // Previously, cmdInit called `resolveDomain` / `resolveAgent` /
425
+ // `sanitiseSlug` once and hard-failed on the first typo — users who
426
+ // mistyped "saass" lost the whole wizard and had to start over. With
427
+ // the retry loop, they just read the error and try again.
428
+ async function promptUntilValid(question, resolver, {
429
+ maxAttempts = 3,
430
+ invalidMessage = 'Invalid selection — try again.',
431
+ } = {}) {
432
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
433
+ const raw = await prompt(question);
434
+ const result = resolver(raw);
435
+ if (result != null && result !== '') return result;
436
+ const remaining = maxAttempts - attempt;
437
+ if (remaining > 0) {
438
+ log(' ' + yellow(`${invalidMessage} (${remaining} attempt${remaining === 1 ? '' : 's'} left)`));
439
+ }
440
+ }
441
+ logError(`Too many invalid attempts — aborting.`);
442
+ process.exit(1);
443
+ }
444
+
162
445
  // --- Utilities ---------------------------------------------------------
163
446
 
164
447
  function sanitiseSlug(input) {
@@ -296,35 +579,21 @@ function today() {
296
579
  return new Date().toISOString().slice(0, 10);
297
580
  }
298
581
 
299
- function copyDir(src, dest, { dryRun = false, transform = null } = {}) {
300
- if (!fs.existsSync(src)) {
301
- throw new Error(`Source directory not found: ${src}`);
302
- }
303
- const copied = [];
304
- (function walk(s, d) {
305
- if (!dryRun) fs.mkdirSync(d, { recursive: true });
306
- for (const entry of fs.readdirSync(s, { withFileTypes: true })) {
307
- const srcPath = path.join(s, entry.name);
308
- let destPath = path.join(d, entry.name);
309
- if (entry.isDirectory()) {
310
- walk(srcPath, destPath);
311
- continue;
312
- }
313
- if (transform) {
314
- const result = transform(srcPath, destPath);
315
- if (!result) continue;
316
- destPath = result.destPath;
317
- if (!dryRun) {
318
- fs.mkdirSync(path.dirname(destPath), { recursive: true });
319
- fs.writeFileSync(destPath, result.content);
320
- }
321
- } else {
322
- if (!dryRun) fs.copyFileSync(srcPath, destPath);
323
- }
324
- copied.push(destPath);
582
+ // Generic recursive copy that mirrors src into dest. Used by copySkills
583
+ // for the references/ folder and for skill-format skill folders, where
584
+ // the source structure is preserved verbatim.
585
+ function copyDirRecursive(src, dest, { dryRun, copied }) {
586
+ if (!dryRun) fs.mkdirSync(dest, { recursive: true });
587
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
588
+ const sp = path.join(src, entry.name);
589
+ const dp = path.join(dest, entry.name);
590
+ if (entry.isDirectory()) {
591
+ copyDirRecursive(sp, dp, { dryRun, copied });
592
+ } else {
593
+ if (!dryRun) fs.copyFileSync(sp, dp);
594
+ copied.push(dp);
325
595
  }
326
- })(src, dest);
327
- return copied;
596
+ }
328
597
  }
329
598
 
330
599
  // Minimal YAML frontmatter parser for SKILL.md files.
@@ -350,8 +619,10 @@ function copyDir(src, dest, { dryRun = false, transform = null } = {}) {
350
619
  // - Quoted scalars (single or double quoted) — names would keep quotes
351
620
  //
352
621
  // Returns { name, description, body }. `description` is always
353
- // flattened to a single line (whitespace collapsed) because the .mdc
354
- // rule format expects a one-line description.
622
+ // flattened to a single line (whitespace collapsed) keeps the
623
+ // downstream consumers (manifest summary, status output, agent skill
624
+ // loaders that expect a single-line description) free of multi-line
625
+ // surprises.
355
626
  function parseSkillFrontmatter(content) {
356
627
  const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
357
628
  if (!fmMatch) {
@@ -400,19 +671,50 @@ function parseSkillFrontmatter(content) {
400
671
  };
401
672
  }
402
673
 
403
- // Transform SKILL.md .mdc for Cursor / Windsurf.
404
- // Other files (references/, templates/) are copied as-is.
405
- function skillToMdc(srcPath, destPath) {
406
- const base = path.basename(srcPath);
407
- if (base !== 'SKILL.md') {
408
- return { destPath, content: fs.readFileSync(srcPath) };
409
- }
410
- const content = fs.readFileSync(srcPath, 'utf8');
411
- const { name, description, body } = parseSkillFrontmatter(content);
412
- const ruleName = name || path.basename(path.dirname(srcPath));
413
- const mdcFrontmatter = `---\ndescription: ${description}\nalwaysApply: false\n---\n\n`;
414
- const newDestPath = path.join(path.dirname(destPath), `${ruleName}.mdc`);
415
- return { destPath: newDestPath, content: mdcFrontmatter + body };
674
+ // Install the package's skills/ tree into the given destination. Every
675
+ // supported agent uses the same Agent Skills layout: each source skill
676
+ // folder lands as `<destRoot>/<skillName>/SKILL.md`. The `references/`
677
+ // folder is copied as-is to `<destRoot>/references/`.
678
+ //
679
+ // Skill names come from the SKILL.md `name:` frontmatter field, falling
680
+ // back to the source folder name. Returns:
681
+ // { copied, items }
682
+ // where `copied` is the list of absolute file paths written and `items`
683
+ // is the list of top-level entries in destRoot that the toolkit owns
684
+ // (used to write the manifest).
685
+ function copySkills(srcRoot, destRoot, { dryRun = false } = {}) {
686
+ if (!fs.existsSync(srcRoot)) {
687
+ throw new Error(`Source directory not found: ${srcRoot}`);
688
+ }
689
+ const copied = [];
690
+ const items = [];
691
+
692
+ if (!dryRun) fs.mkdirSync(destRoot, { recursive: true });
693
+
694
+ for (const entry of fs.readdirSync(srcRoot, { withFileTypes: true })) {
695
+ if (!entry.isDirectory()) continue;
696
+ const srcPath = path.join(srcRoot, entry.name);
697
+
698
+ if (entry.name === 'references') {
699
+ const refDest = path.join(destRoot, 'references');
700
+ copyDirRecursive(srcPath, refDest, { dryRun, copied });
701
+ items.push('references');
702
+ continue;
703
+ }
704
+
705
+ // Skill folder. Read its SKILL.md to get the canonical name.
706
+ const skillMdSrc = path.join(srcPath, 'SKILL.md');
707
+ if (!fs.existsSync(skillMdSrc)) continue; // not a skill folder
708
+ const content = fs.readFileSync(skillMdSrc, 'utf8');
709
+ const { name } = parseSkillFrontmatter(content);
710
+ const skillName = name || entry.name;
711
+
712
+ const skillDestDir = path.join(destRoot, skillName);
713
+ copyDirRecursive(srcPath, skillDestDir, { dryRun, copied });
714
+ items.push(skillName);
715
+ }
716
+
717
+ return { copied, items };
416
718
  }
417
719
 
418
720
  // Path to the AGENTS.md template file. Lives next to the rest of the
@@ -429,6 +731,13 @@ function skillToMdc(srcPath, destPath) {
429
731
  // 21-skill list and ordering; keep that template in sync with it.
430
732
  const AGENTS_TEMPLATE_PATH = path.join(SKILLS_DIR, 'references', 'templates', 'agents-template.md');
431
733
 
734
+ // Anchor markers delimit the block inside AGENTS.md that `ba-toolkit
735
+ // init` owns and is allowed to rewrite on re-init. Everything outside
736
+ // the anchors (Pipeline Status, Key Constraints, Open Questions, user
737
+ // notes) is preserved untouched. See agents-template.md.
738
+ const AGENTS_MANAGED_BEGIN = '<!-- ba-toolkit:begin managed -->';
739
+ const AGENTS_MANAGED_END = '<!-- ba-toolkit:end managed -->';
740
+
432
741
  function renderAgentsMd({ name, slug, domain }) {
433
742
  let template;
434
743
  try {
@@ -443,9 +752,50 @@ function renderAgentsMd({ name, slug, domain }) {
443
752
  .replace(/\[DATE\]/g, today());
444
753
  }
445
754
 
755
+ // Merge the fresh AGENTS.md content into whatever already exists at
756
+ // the project root. Three branches:
757
+ //
758
+ // 1. No existing file (existing == null) — return the fresh template,
759
+ // action 'created'.
760
+ // 2. Existing file has both anchor markers — replace only the managed
761
+ // block content between the anchors, leave the rest of the file
762
+ // (Pipeline Status, Key Constraints, user notes) untouched. Action
763
+ // 'merged'.
764
+ // 3. Existing file has no anchors — it's either a legacy AGENTS.md
765
+ // from a pre-merge version of the toolkit or a fully user-authored
766
+ // file. Leave it untouched and return { action: 'preserved' } so
767
+ // the caller can print a note. We never silently overwrite
768
+ // user content.
769
+ //
770
+ // Pure function for easy testing. Exported so test/cli.test.js can
771
+ // cover all three branches without spawning a process.
772
+ function mergeAgentsMd(existing, ctx) {
773
+ const fresh = renderAgentsMd(ctx);
774
+ if (existing == null) {
775
+ return { content: fresh, action: 'created' };
776
+ }
777
+ const beginIdx = existing.indexOf(AGENTS_MANAGED_BEGIN);
778
+ const endIdx = existing.indexOf(AGENTS_MANAGED_END);
779
+ if (beginIdx === -1 || endIdx === -1 || endIdx < beginIdx) {
780
+ return { content: existing, action: 'preserved' };
781
+ }
782
+ const freshBeginIdx = fresh.indexOf(AGENTS_MANAGED_BEGIN);
783
+ const freshEndIdx = fresh.indexOf(AGENTS_MANAGED_END);
784
+ if (freshBeginIdx === -1 || freshEndIdx === -1) {
785
+ // Template is broken — fall back to returning fresh. Should be
786
+ // caught in unit tests if the template file ever loses its anchors.
787
+ return { content: fresh, action: 'created' };
788
+ }
789
+ const freshManaged = fresh.slice(freshBeginIdx, freshEndIdx + AGENTS_MANAGED_END.length);
790
+ const before = existing.slice(0, beginIdx);
791
+ const after = existing.slice(endIdx + AGENTS_MANAGED_END.length);
792
+ return { content: before + freshManaged + after, action: 'merged' };
793
+ }
794
+
446
795
  // --- Commands ----------------------------------------------------------
447
796
 
448
797
  async function cmdInit(args) {
798
+ printBanner();
449
799
  log('');
450
800
  log(' ' + cyan('BA Toolkit — New Project Setup'));
451
801
  log(' ' + cyan('================================'));
@@ -480,20 +830,43 @@ async function cmdInit(args) {
480
830
  }
481
831
  slug = derived;
482
832
  } else if (derived) {
483
- const custom = await prompt(` Project slug [${cyan(derived)}]: `);
484
- slug = custom || derived;
833
+ // Default branch: the derived slug is offered as the suggested
834
+ // answer. Empty input accepts the suggestion; anything the user
835
+ // types is run through sanitiseSlug and must produce something
836
+ // non-empty — otherwise re-prompt.
837
+ slug = await promptUntilValid(
838
+ ` Project slug [${cyan(derived)}]: `,
839
+ (raw) => {
840
+ const typed = String(raw || '').trim();
841
+ if (!typed) return derived;
842
+ const cleaned = sanitiseSlug(typed);
843
+ return cleaned || null;
844
+ },
845
+ { invalidMessage: 'Invalid slug — must produce at least one ASCII letter/digit after sanitisation.' },
846
+ );
485
847
  } else {
486
848
  log(' ' + gray(`(could not derive a slug from "${name}" — please type one manually)`));
487
- slug = await prompt(' Project slug (lowercase, hyphens only): ');
849
+ slug = await promptUntilValid(
850
+ ' Project slug (lowercase, hyphens only): ',
851
+ (raw) => {
852
+ const cleaned = sanitiseSlug(String(raw || '').trim());
853
+ return cleaned || null;
854
+ },
855
+ { invalidMessage: 'Invalid slug — must contain at least one ASCII letter or digit.' },
856
+ );
488
857
  }
489
858
  }
859
+ // At this point `slug` is already a sanitised, non-empty string from
860
+ // one of the branches above. The final sanitiseSlug call is a
861
+ // defensive no-op for the flag path (--slug) where we haven't
862
+ // cleaned it yet.
490
863
  slug = sanitiseSlug(slug);
491
864
  if (!slug) {
492
865
  logError('Invalid or empty slug.');
493
866
  process.exit(1);
494
867
  }
495
868
 
496
- // --- 3. Domain (numbered menu) ---
869
+ // --- 3. Domain (arrow menu in TTY, numbered fallback elsewhere) ---
497
870
  const domainFlag = stringFlag(args, 'domain');
498
871
  let domain;
499
872
  if (domainFlag) {
@@ -504,20 +877,18 @@ async function cmdInit(args) {
504
877
  process.exit(1);
505
878
  }
506
879
  } else {
507
- log('');
508
- log(' ' + yellow('Pick a domain:'));
509
- const domainNameWidth = Math.max(...DOMAINS.map((d) => d.name.length));
510
- DOMAINS.forEach((d, i) => {
511
- const idx = String(i + 1).padStart(2);
512
- log(` ${idx}) ${bold(d.name.padEnd(domainNameWidth))} ${gray('— ' + d.desc)}`);
513
- });
514
- log('');
515
- const raw = await prompt(` Select [1-${DOMAINS.length}]: `);
516
- domain = resolveDomain(raw);
517
- if (!domain) {
518
- logError(`Invalid selection: ${raw || '(empty)'}`);
519
- process.exit(1);
880
+ const chosen = await selectMenu(
881
+ DOMAINS.map((d) => ({ id: d.id, label: d.name, desc: d.desc })),
882
+ {
883
+ title: 'Pick a domain:',
884
+ fallbackPrompt: ` Select [1-${DOMAINS.length}]: `,
885
+ },
886
+ );
887
+ if (chosen == null) {
888
+ log(' ' + yellow('Cancelled.'));
889
+ process.exit(130);
520
890
  }
891
+ domain = chosen.id;
521
892
  }
522
893
 
523
894
  // --- 4. Agent (numbered menu), unless --no-install ---
@@ -533,21 +904,19 @@ async function cmdInit(args) {
533
904
  process.exit(1);
534
905
  }
535
906
  } else {
536
- log('');
537
- log(' ' + yellow('Pick your AI agent:'));
538
907
  const agentEntries = Object.entries(AGENTS);
539
- const agentNameWidth = Math.max(...agentEntries.map(([, a]) => a.name.length));
540
- agentEntries.forEach(([id, a], i) => {
541
- const idx = String(i + 1).padStart(2);
542
- log(` ${idx}) ${bold(a.name.padEnd(agentNameWidth))} ${gray('(' + id + ')')}`);
543
- });
544
- log('');
545
- const raw = await prompt(` Select [1-${agentEntries.length}]: `);
546
- agentId = resolveAgent(raw);
547
- if (!agentId) {
548
- logError(`Invalid selection: ${raw || '(empty)'}`);
549
- process.exit(1);
908
+ const chosen = await selectMenu(
909
+ agentEntries.map(([id, a]) => ({ id, label: a.name, desc: '(' + id + ')' })),
910
+ {
911
+ title: 'Pick your AI agent:',
912
+ fallbackPrompt: ` Select [1-${agentEntries.length}]: `,
913
+ },
914
+ );
915
+ if (chosen == null) {
916
+ log(' ' + yellow('Cancelled.'));
917
+ process.exit(130);
550
918
  }
919
+ agentId = chosen.id;
551
920
  }
552
921
  }
553
922
 
@@ -563,18 +932,23 @@ async function cmdInit(args) {
563
932
  log(` exists ${outputDir}`);
564
933
  }
565
934
 
935
+ // AGENTS.md: merge-on-reinit instead of overwrite. Everything outside
936
+ // the managed block (Pipeline Status, Key Constraints, user notes) is
937
+ // preserved. See mergeAgentsMd for the three branches (created,
938
+ // merged, preserved).
566
939
  const agentsPath = 'AGENTS.md';
567
- let writeAgents = true;
568
- if (fs.existsSync(agentsPath)) {
569
- const answer = await prompt(' AGENTS.md already exists. Overwrite? (y/N): ');
570
- if (answer.toLowerCase() !== 'y') {
571
- writeAgents = false;
572
- log(' skipped AGENTS.md');
573
- }
574
- }
575
- if (writeAgents) {
576
- fs.writeFileSync(agentsPath, renderAgentsMd({ name, slug, domain }));
577
- log(' created AGENTS.md');
940
+ const existingAgents = fs.existsSync(agentsPath)
941
+ ? fs.readFileSync(agentsPath, 'utf8')
942
+ : null;
943
+ const { content: agentsContent, action: agentsAction } = mergeAgentsMd(
944
+ existingAgents,
945
+ { name, slug, domain },
946
+ );
947
+ if (agentsAction === 'preserved') {
948
+ log(' ' + gray('preserved AGENTS.md (no ba-toolkit managed block — left untouched)'));
949
+ } else {
950
+ fs.writeFileSync(agentsPath, agentsContent);
951
+ log(` ${agentsAction === 'merged' ? 'updated ' : 'created '} AGENTS.md`);
578
952
  }
579
953
 
580
954
  // --- 6. Install skills for the selected agent ---
@@ -620,14 +994,18 @@ async function cmdInit(args) {
620
994
  log('');
621
995
  }
622
996
 
623
- // Marker file written into the install destination after a successful copy.
624
- // Lets `upgrade` and `status` (future command) tell which package version
625
- // is currently installed without diffing every file. Hidden file with no
626
- // `.md` / `.mdc` extension so the agent's skill loader ignores it.
627
- const SENTINEL_FILENAME = '.ba-toolkit-version';
997
+ // Manifest written into the install destination after a successful copy.
998
+ // Replaces the v1.x version sentinel now also tracks WHICH items
999
+ // belong to BA Toolkit, so uninstall/upgrade can selectively remove
1000
+ // only what we own without touching the user's other skills sitting in
1001
+ // the same directory.
1002
+ //
1003
+ // Hidden filename with no `.md` extension so the skill loader of every
1004
+ // supported agent ignores it.
1005
+ const MANIFEST_FILENAME = '.ba-toolkit-manifest.json';
628
1006
 
629
- function readSentinel(destDir) {
630
- const p = path.join(destDir, SENTINEL_FILENAME);
1007
+ function readManifest(destDir) {
1008
+ const p = path.join(destDir, MANIFEST_FILENAME);
631
1009
  if (!fs.existsSync(p)) return null;
632
1010
  try {
633
1011
  return JSON.parse(fs.readFileSync(p, 'utf8'));
@@ -636,45 +1014,51 @@ function readSentinel(destDir) {
636
1014
  }
637
1015
  }
638
1016
 
639
- function writeSentinel(destDir) {
1017
+ function writeManifest(destDir, items) {
640
1018
  const payload = {
641
1019
  version: PKG.version,
642
1020
  installedAt: new Date().toISOString(),
1021
+ items,
643
1022
  };
644
1023
  fs.writeFileSync(
645
- path.join(destDir, SENTINEL_FILENAME),
1024
+ path.join(destDir, MANIFEST_FILENAME),
646
1025
  JSON.stringify(payload, null, 2) + '\n',
647
1026
  );
648
1027
  }
649
1028
 
650
- // Core install logic. Shared between `cmdInstall` (standalone), `cmdInit`
651
- // (full setup), and `cmdUpgrade`. Returns true on success, false if the
652
- // user declined to overwrite an existing destination. Pass `force: true`
653
- // to skip the overwrite prompt `cmdUpgrade` uses this because it has
654
- // already wiped the destination and explicitly knows the overwrite is ok.
655
- async function runInstall({ agentId, isGlobal, isProject, dryRun, showHeader = true, force = false }) {
656
- const agent = AGENTS[agentId];
657
- if (!agent) {
658
- logError(`Unknown agent: ${agentId}`);
659
- log('Supported: ' + Object.keys(AGENTS).join(', '));
660
- process.exit(1);
661
- }
662
-
663
- let effectiveGlobal = !!isGlobal;
664
- if (!isGlobal && !isProject) {
665
- // Default: project-level if supported, otherwise global
666
- effectiveGlobal = !agent.projectPath;
667
- }
668
- if (effectiveGlobal && !agent.globalPath) {
669
- logError(`${agent.name} does not support --global install.`);
670
- process.exit(1);
1029
+ // Detect the v1.x install layout: every previous install path nested
1030
+ // our 21 skills under an extra `ba-toolkit/` folder, which made them
1031
+ // invisible to every agent's skill loader. Returns the absolute paths
1032
+ // of any legacy folders that still exist for the given agent, so the
1033
+ // caller can warn the user to clean them up before installing v2.0.
1034
+ function detectLegacyInstall(agent) {
1035
+ const candidates = [];
1036
+ if (agent.projectPath) {
1037
+ candidates.push(path.resolve(process.cwd(), agent.projectPath, 'ba-toolkit'));
671
1038
  }
672
- if (!effectiveGlobal && !agent.projectPath) {
673
- logError(`${agent.name} does not support project-level install. Use --global.`);
674
- process.exit(1);
1039
+ if (agent.globalPath) {
1040
+ candidates.push(path.join(agent.globalPath, 'ba-toolkit'));
675
1041
  }
1042
+ return candidates.filter((p) => fs.existsSync(p));
1043
+ }
676
1044
 
677
- const destDir = effectiveGlobal ? agent.globalPath : path.resolve(process.cwd(), agent.projectPath);
1045
+ // Core install logic. Shared between `cmdInstall` (standalone), `cmdInit`
1046
+ // (full setup), and `cmdUpgrade`. Returns true on success, false if the
1047
+ // user declined to overwrite an existing install. Pass `force: true` to
1048
+ // skip the overwrite prompt — cmdUpgrade uses this because it has
1049
+ // already removed the previous install via the manifest.
1050
+ //
1051
+ // v2.0 model: the destination directory is shared with the user's other
1052
+ // skills. We never wipe destDir wholesale. Before copying we read the
1053
+ // manifest (if any) and remove only the items the previous v2.0 install
1054
+ // owned, then copy the new tree and write a fresh manifest. Items that
1055
+ // don't appear in the manifest are not ours and never get touched.
1056
+ async function runInstall({ agentId, isGlobal, isProject, dryRun, showHeader = true, force = false }) {
1057
+ const { agent, destDir, effectiveGlobal } = resolveAgentDestination({
1058
+ agentId,
1059
+ isGlobal,
1060
+ isProject,
1061
+ });
678
1062
 
679
1063
  if (showHeader) {
680
1064
  log('');
@@ -687,37 +1071,87 @@ async function runInstall({ agentId, isGlobal, isProject, dryRun, showHeader = t
687
1071
  log(` source: ${SKILLS_DIR}`);
688
1072
  log(` destination: ${destDir}`);
689
1073
  log(` scope: ${effectiveGlobal ? 'global (user-wide)' : 'project-level'}`);
690
- log(` format: ${agent.format === 'mdc' ? '.mdc (converted from SKILL.md)' : 'SKILL.md (native)'}`);
1074
+ log(` format: SKILL.md (native)`);
691
1075
  if (dryRun) log(' ' + yellow('mode: dry-run (no files will be written)'));
692
1076
 
693
- if (fs.existsSync(destDir) && !dryRun && !force) {
694
- const answer = await prompt(` ${destDir} already exists. Overwrite? (y/N): `);
1077
+ // Warn about a v1.x wrapper folder if one is sitting in the same
1078
+ // location. Don't auto-delete could be the user's working state.
1079
+ warnLegacyInstall(agent);
1080
+
1081
+ // If a previous v2.0 install lives here, ask before replacing it.
1082
+ // The manifest is the only signal that the directory contains our
1083
+ // files; without it we treat the install as fresh and let copySkills
1084
+ // happily add to whatever's already there.
1085
+ const existingManifest = readManifest(destDir);
1086
+ if (existingManifest && !dryRun && !force) {
1087
+ log(` existing: v${existingManifest.version} (${existingManifest.items.length} items)`);
1088
+ const answer = await prompt(' Replace existing BA Toolkit install? (y/N): ');
695
1089
  if (answer.toLowerCase() !== 'y') {
696
1090
  log(' cancelled.');
697
1091
  return false;
698
1092
  }
699
1093
  }
700
1094
 
701
- const transform = agent.format === 'mdc' ? skillToMdc : null;
702
- let copied;
1095
+ // Selectively remove the previous install's items (and only those)
1096
+ // before copying the new tree. Sentinel-style file is removed too.
1097
+ if (existingManifest && !dryRun) {
1098
+ removeManifestItems(destDir, existingManifest);
1099
+ }
1100
+
1101
+ let result;
703
1102
  try {
704
- copied = copyDir(SKILLS_DIR, destDir, { dryRun, transform });
1103
+ result = copySkills(SKILLS_DIR, destDir, { dryRun });
705
1104
  } catch (err) {
706
1105
  logError(err.message);
707
1106
  process.exit(1);
708
1107
  }
709
1108
 
710
1109
  if (!dryRun) {
711
- writeSentinel(destDir);
1110
+ writeManifest(destDir, result.items);
712
1111
  }
713
1112
 
714
- log(' ' + green(`${dryRun ? 'would copy' : 'copied'} ${copied.length} files.`));
715
- if (!dryRun && agent.format === 'mdc') {
716
- log(' ' + gray('SKILL.md files converted to .mdc rule format.'));
717
- }
1113
+ log(' ' + green(`${dryRun ? 'would copy' : 'copied'} ${result.copied.length} files (${result.items.length} items).`));
718
1114
  return true;
719
1115
  }
720
1116
 
1117
+ // Remove every item listed in the given manifest from destDir, then
1118
+ // remove the manifest file itself. Items are top-level entries
1119
+ // relative to destDir — folder names like `brief`, `srs`, …,
1120
+ // `references`. Anything not in the manifest is left alone, including
1121
+ // the user's other skills sitting in the same directory.
1122
+ function removeManifestItems(destDir, manifest) {
1123
+ for (const item of manifest.items) {
1124
+ const p = path.join(destDir, item);
1125
+ if (fs.existsSync(p)) {
1126
+ fs.rmSync(p, { recursive: true, force: true });
1127
+ }
1128
+ }
1129
+ const manifestPath = path.join(destDir, MANIFEST_FILENAME);
1130
+ if (fs.existsSync(manifestPath)) {
1131
+ fs.rmSync(manifestPath, { force: true });
1132
+ }
1133
+ }
1134
+
1135
+ // Print a yellow warning if the v1.x wrapper directory is still around
1136
+ // in the same skills root. The wrapper made every shipped skill
1137
+ // invisible to the agent, so any user upgrading from v1 needs to
1138
+ // remove it manually before v2.0 can install correctly.
1139
+ function warnLegacyInstall(agent) {
1140
+ const legacy = detectLegacyInstall(agent);
1141
+ if (legacy.length === 0) return;
1142
+ log('');
1143
+ log(' ' + yellow('! Legacy v1.x install detected — must be removed before v2.0 will work:'));
1144
+ for (const p of legacy) {
1145
+ log(' ' + yellow(` ${p}`));
1146
+ }
1147
+ log(' ' + yellow(' v2.0 dropped the ba-toolkit/ wrapper folder; the agent ignored every skill nested under it.'));
1148
+ log(' ' + yellow(' Remove the legacy folder manually:'));
1149
+ for (const p of legacy) {
1150
+ log(' ' + gray(` rm -rf "${p}"`));
1151
+ }
1152
+ log('');
1153
+ }
1154
+
721
1155
  async function cmdInstall(args) {
722
1156
  const agentId = stringFlag(args, 'for');
723
1157
  if (!agentId) {
@@ -775,74 +1209,82 @@ function cmdStatus() {
775
1209
  log(` scanning from: ${process.cwd()}`);
776
1210
  log('');
777
1211
 
778
- // Walk every (agent × scope) combination and collect the ones whose
779
- // destination directory actually exists. Project-scope paths resolve
780
- // against the current working directory; global paths are absolute.
1212
+ // Walk every (agent × scope) combination and collect the ones that
1213
+ // have a v2.0 manifest. Project-scope paths resolve against the
1214
+ // current working directory; global paths are absolute.
781
1215
  const rows = [];
1216
+ const legacyRows = [];
1217
+
1218
+ const checkLocation = (agent, agentId, scope, dir) => {
1219
+ if (!fs.existsSync(dir)) return;
1220
+ const manifest = readManifest(dir);
1221
+ if (manifest) {
1222
+ rows.push({
1223
+ agentName: agent.name,
1224
+ agentId,
1225
+ scope,
1226
+ path: dir,
1227
+ version: manifest.version,
1228
+ installedAt: manifest.installedAt,
1229
+ itemCount: manifest.items.length,
1230
+ });
1231
+ }
1232
+ };
1233
+
782
1234
  for (const [agentId, agent] of Object.entries(AGENTS)) {
783
1235
  if (agent.projectPath) {
784
1236
  const projectDir = path.resolve(process.cwd(), agent.projectPath);
785
- if (fs.existsSync(projectDir)) {
786
- const sentinel = readSentinel(projectDir);
787
- rows.push({
788
- agentName: agent.name,
789
- agentId,
790
- scope: 'project',
791
- path: projectDir,
792
- version: sentinel ? sentinel.version : null,
793
- installedAt: sentinel ? sentinel.installedAt : null,
794
- });
795
- }
1237
+ checkLocation(agent, agentId, 'project', projectDir);
796
1238
  }
797
1239
  if (agent.globalPath) {
798
- if (fs.existsSync(agent.globalPath)) {
799
- const sentinel = readSentinel(agent.globalPath);
800
- rows.push({
801
- agentName: agent.name,
802
- agentId,
803
- scope: 'global',
804
- path: agent.globalPath,
805
- version: sentinel ? sentinel.version : null,
806
- installedAt: sentinel ? sentinel.installedAt : null,
807
- });
808
- }
1240
+ checkLocation(agent, agentId, 'global', agent.globalPath);
1241
+ }
1242
+ // Surface any v1.x wrapper folders as a separate "legacy" row.
1243
+ for (const legacyPath of detectLegacyInstall(agent)) {
1244
+ legacyRows.push({ agentName: agent.name, agentId, path: legacyPath });
809
1245
  }
810
1246
  }
811
1247
 
812
- if (rows.length === 0) {
1248
+ if (rows.length === 0 && legacyRows.length === 0) {
813
1249
  log(' ' + gray('No BA Toolkit installations found in any known location.'));
814
1250
  log(' ' + gray("Run 'ba-toolkit install --for <agent>' to install one."));
815
1251
  log('');
816
1252
  return;
817
1253
  }
818
1254
 
819
- log(` Found ${bold(rows.length)} installation${rows.length === 1 ? '' : 's'}:`);
820
- log('');
821
-
822
- for (const row of rows) {
823
- let versionLabel;
824
- if (!row.version) {
825
- versionLabel = gray('(unknown — pre-1.4 install with no sentinel)');
826
- } else if (row.version === PKG.version) {
827
- versionLabel = green(row.version + ' (current)');
828
- } else {
829
- versionLabel = yellow(row.version + ' (outdated)');
830
- }
831
- log(` ${bold(row.agentName)} ${gray('(' + row.agentId + ', ' + row.scope + ')')}`);
832
- log(` path: ${row.path}`);
833
- log(` version: ${versionLabel}`);
834
- if (row.installedAt) {
1255
+ if (rows.length > 0) {
1256
+ log(` Found ${bold(rows.length)} installation${rows.length === 1 ? '' : 's'}:`);
1257
+ log('');
1258
+ for (const row of rows) {
1259
+ const versionLabel = row.version === PKG.version
1260
+ ? green(row.version + ' (current)')
1261
+ : yellow(row.version + ' (outdated)');
1262
+ log(` ${bold(row.agentName)} ${gray('(' + row.agentId + ', ' + row.scope + ')')}`);
1263
+ log(` path: ${row.path}`);
1264
+ log(` version: ${versionLabel}`);
1265
+ log(` items: ${row.itemCount}`);
835
1266
  log(` installed: ${gray(row.installedAt)}`);
1267
+ log('');
836
1268
  }
1269
+ }
1270
+
1271
+ if (legacyRows.length > 0) {
1272
+ log(' ' + yellow(`Found ${legacyRows.length} legacy v1.x install${legacyRows.length === 1 ? '' : 's'} (broken — invisible to the agent):`));
837
1273
  log('');
1274
+ for (const row of legacyRows) {
1275
+ log(` ${bold(row.agentName)} ${gray('(' + row.agentId + ', legacy wrapper)')}`);
1276
+ log(` path: ${row.path}`);
1277
+ log(` fix: ` + gray(`rm -rf "${row.path}" && ba-toolkit install --for ${row.agentId}`));
1278
+ log('');
1279
+ }
838
1280
  }
839
1281
 
840
- const stale = rows.filter((r) => !r.version || r.version !== PKG.version);
1282
+ const stale = rows.filter((r) => r.version !== PKG.version);
841
1283
  if (stale.length > 0) {
842
1284
  log(' ' + yellow(`${stale.length} installation${stale.length === 1 ? '' : 's'} not at version ${PKG.version}.`));
843
1285
  log(' ' + gray("Run 'ba-toolkit upgrade --for <agent>' to refresh."));
844
1286
  log('');
845
- } else {
1287
+ } else if (rows.length > 0) {
846
1288
  log(' ' + green('All installations are up to date.'));
847
1289
  log('');
848
1290
  }
@@ -869,54 +1311,47 @@ async function cmdUpgrade(args) {
869
1311
  log(` destination: ${destDir}`);
870
1312
  log(` scope: ${effectiveGlobal ? 'global (user-wide)' : 'project-level'}`);
871
1313
 
1314
+ warnLegacyInstall(agent);
1315
+
872
1316
  if (!fs.existsSync(destDir)) {
873
1317
  log('');
874
1318
  log(' ' + gray(`No installation found at ${destDir}.`));
875
- log(' ' + gray(`Run \`ba-toolkit install --for ${agentId}\` first.`));
1319
+ log(' ' + gray(`Run 'ba-toolkit install --for ${agentId}' first.`));
876
1320
  log('');
877
1321
  return;
878
1322
  }
879
1323
 
880
- const sentinel = readSentinel(destDir);
881
- const currentVersion = PKG.version;
882
- const installedVersion = sentinel ? sentinel.version : null;
1324
+ const manifest = readManifest(destDir);
1325
+ if (!manifest) {
1326
+ log('');
1327
+ log(' ' + gray('No BA Toolkit manifest found in this destination.'));
1328
+ log(' ' + gray(`Run 'ba-toolkit install --for ${agentId}' to install fresh.`));
1329
+ log('');
1330
+ return;
1331
+ }
883
1332
 
884
- if (installedVersion === currentVersion) {
885
- log(` installed: ${installedVersion} (current)`);
1333
+ const currentVersion = PKG.version;
1334
+ if (manifest.version === currentVersion) {
1335
+ log(` installed: ${manifest.version} (current)`);
886
1336
  log(` package: ${currentVersion}`);
887
1337
  log('');
888
1338
  log(' ' + green('Already up to date.'));
889
- log(' ' + gray(`To force a clean reinstall, run \`ba-toolkit install --for ${agentId}\`.`));
1339
+ log(' ' + gray(`To force a clean reinstall, run 'ba-toolkit install --for ${agentId}'.`));
890
1340
  log('');
891
1341
  return;
892
1342
  }
893
1343
 
894
- log(` installed: ${installedVersion || gray('(unknown — pre-1.4 install with no sentinel)')}`);
1344
+ log(` installed: ${manifest.version}`);
895
1345
  log(` package: ${currentVersion}`);
1346
+ log(` items: ${manifest.items.length}`);
896
1347
  if (dryRun) log(' ' + yellow('mode: dry-run (no files will be written)'));
897
1348
  log('');
898
1349
 
899
- // Safety: same guard as cmdUninstall — never rmSync anything that
900
- // doesn't look like a ba-toolkit folder.
901
- if (path.basename(destDir) !== 'ba-toolkit') {
902
- logError(`Refusing to upgrade suspicious destination (not a ba-toolkit folder): ${destDir}`);
903
- process.exit(1);
904
- }
905
-
906
- // Count files in the existing install for the dry-run preview.
907
1350
  if (dryRun) {
908
- let existingCount = 0;
909
- (function walk(d) {
910
- for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
911
- const p = path.join(d, entry.name);
912
- if (entry.isDirectory()) walk(p);
913
- else existingCount++;
914
- }
915
- })(destDir);
916
- log(' ' + yellow(`would remove ${existingCount} existing files`));
1351
+ log(' ' + yellow(`would remove ${manifest.items.length} previously-installed items, then re-copy the new tree`));
917
1352
  } else {
918
- log(' ' + green('Removing previous install...'));
919
- fs.rmSync(destDir, { recursive: true, force: true });
1353
+ log(' ' + green('Removing previous install (manifest-driven)...'));
1354
+ removeManifestItems(destDir, manifest);
920
1355
  }
921
1356
 
922
1357
  const ok = await runInstall({
@@ -956,51 +1391,49 @@ async function cmdUninstall(args) {
956
1391
  log(` destination: ${destDir}`);
957
1392
  log(` scope: ${effectiveGlobal ? 'global (user-wide)' : 'project-level'}`);
958
1393
  if (dryRun) log(' ' + yellow('mode: dry-run (no files will be removed)'));
959
- log('');
960
1394
 
961
- // Safety: this is the only place in the CLI that calls fs.rmSync with
962
- // recursive: true. Refuse to proceed unless the destination is clearly
963
- // a ba-toolkit folder (the install paths in AGENTS all end in
964
- // `ba-toolkit/`). Without this check, a corrupted AGENTS entry or a
965
- // future bug could turn this into `rm -rf $HOME`.
966
- if (path.basename(destDir) !== 'ba-toolkit') {
967
- logError(`Refusing to remove suspicious destination (not a ba-toolkit folder): ${destDir}`);
968
- process.exit(1);
969
- }
1395
+ warnLegacyInstall(agent);
970
1396
 
971
1397
  if (!fs.existsSync(destDir)) {
1398
+ log('');
972
1399
  log(' ' + gray(`Nothing to uninstall — ${destDir} does not exist.`));
973
1400
  log('');
974
1401
  return;
975
1402
  }
976
1403
 
977
- // Count files for the preview message and final confirmation.
978
- let fileCount = 0;
979
- (function walk(d) {
980
- for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
981
- const p = path.join(d, entry.name);
982
- if (entry.isDirectory()) walk(p);
983
- else fileCount++;
984
- }
985
- })(destDir);
1404
+ // The manifest is the proof we own anything in this directory. The
1405
+ // destination is shared with the user's other skills/rules, so we
1406
+ // can't just `rm -rf destDir` — we'd nuke unrelated files. Without
1407
+ // a manifest, refuse and tell the user.
1408
+ const manifest = readManifest(destDir);
1409
+ if (!manifest) {
1410
+ log('');
1411
+ log(' ' + gray('No BA Toolkit manifest found in this destination.'));
1412
+ log(' ' + gray('Either nothing was installed here, or the install pre-dates v2.0.'));
1413
+ log(' ' + gray('For a v1.x legacy wrapper, see the warning above (if any) for the path to remove manually.'));
1414
+ log('');
1415
+ return;
1416
+ }
986
1417
 
987
- log(` Found ${bold(fileCount)} files in the destination.`);
1418
+ log('');
1419
+ log(` installed: ${manifest.version}`);
1420
+ log(` items: ${bold(manifest.items.length)} (${manifest.items.slice(0, 5).join(', ')}${manifest.items.length > 5 ? ', …' : ''})`);
988
1421
 
989
1422
  if (dryRun) {
990
- log(' ' + yellow(`would remove ${fileCount} files from ${destDir}.`));
1423
+ log(' ' + yellow(`would remove ${manifest.items.length} items + the manifest from ${destDir}`));
991
1424
  log('');
992
1425
  return;
993
1426
  }
994
1427
 
995
1428
  log('');
996
- const answer = await prompt(` Remove ${destDir}? (y/N): `);
1429
+ const answer = await prompt(` Remove ${manifest.items.length} BA Toolkit items from ${destDir}? (y/N): `);
997
1430
  if (answer.toLowerCase() !== 'y') {
998
1431
  log(' Cancelled.');
999
1432
  log('');
1000
1433
  return;
1001
1434
  }
1002
- fs.rmSync(destDir, { recursive: true, force: true });
1003
- log(' ' + green(`Removed ${fileCount} files from ${destDir}.`));
1435
+ removeManifestItems(destDir, manifest);
1436
+ log(' ' + green(`Removed ${manifest.items.length} items.`));
1004
1437
  log(' ' + yellow(agent.restartHint));
1005
1438
  log('');
1006
1439
  }
@@ -1143,8 +1576,12 @@ module.exports = {
1143
1576
  levenshtein,
1144
1577
  closestMatch,
1145
1578
  parseSkillFrontmatter,
1146
- readSentinel,
1579
+ readManifest,
1580
+ detectLegacyInstall,
1147
1581
  renderAgentsMd,
1582
+ mergeAgentsMd,
1583
+ menuStep,
1584
+ renderMenu,
1148
1585
  KNOWN_FLAGS,
1149
1586
  DOMAINS,
1150
1587
  AGENTS,