@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/CHANGELOG.md +117 -1
- package/README.md +33 -28
- package/bin/ba-toolkit.js +696 -259
- package/package.json +2 -2
- package/skills/ac/SKILL.md +4 -2
- package/skills/analyze/SKILL.md +1 -1
- package/skills/apicontract/SKILL.md +4 -2
- package/skills/brief/SKILL.md +4 -2
- package/skills/clarify/SKILL.md +1 -1
- package/skills/datadict/SKILL.md +4 -2
- package/skills/estimate/SKILL.md +1 -1
- package/skills/export/SKILL.md +1 -1
- package/skills/glossary/SKILL.md +1 -1
- package/skills/handoff/SKILL.md +1 -1
- package/skills/nfr/SKILL.md +4 -2
- package/skills/principles/SKILL.md +4 -2
- package/skills/references/interview-protocol.md +53 -0
- package/skills/references/templates/agents-template.md +3 -1
- package/skills/research/SKILL.md +4 -2
- package/skills/risk/SKILL.md +1 -1
- package/skills/scenarios/SKILL.md +4 -2
- package/skills/sprint/SKILL.md +1 -1
- package/skills/srs/SKILL.md +4 -2
- package/skills/stories/SKILL.md +4 -2
- package/skills/trace/SKILL.md +1 -1
- package/skills/usecases/SKILL.md +4 -2
- package/skills/wireframes/SKILL.md +4 -2
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
|
|
25
|
-
globalPath: path.join(os.homedir(), '.claude', 'skills'
|
|
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'
|
|
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
|
|
39
|
-
globalPath: path.join(os.homedir(), '.gemini', 'skills'
|
|
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/
|
|
46
|
-
globalPath: null, // Cursor
|
|
47
|
-
|
|
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/
|
|
53
|
-
globalPath: null,
|
|
54
|
-
|
|
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
|
|
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
|
|
130
|
-
//
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
(
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
}
|
|
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)
|
|
354
|
-
//
|
|
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
|
-
//
|
|
404
|
-
//
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
484
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
-
//
|
|
624
|
-
//
|
|
625
|
-
//
|
|
626
|
-
//
|
|
627
|
-
|
|
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
|
|
630
|
-
const p = path.join(destDir,
|
|
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
|
|
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,
|
|
1024
|
+
path.join(destDir, MANIFEST_FILENAME),
|
|
646
1025
|
JSON.stringify(payload, null, 2) + '\n',
|
|
647
1026
|
);
|
|
648
1027
|
}
|
|
649
1028
|
|
|
650
|
-
//
|
|
651
|
-
//
|
|
652
|
-
//
|
|
653
|
-
//
|
|
654
|
-
//
|
|
655
|
-
|
|
656
|
-
const
|
|
657
|
-
if (
|
|
658
|
-
|
|
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 (
|
|
673
|
-
|
|
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
|
-
|
|
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:
|
|
1074
|
+
log(` format: SKILL.md (native)`);
|
|
691
1075
|
if (dryRun) log(' ' + yellow('mode: dry-run (no files will be written)'));
|
|
692
1076
|
|
|
693
|
-
|
|
694
|
-
|
|
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
|
-
|
|
702
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
779
|
-
//
|
|
780
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
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
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
}
|
|
829
|
-
|
|
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) =>
|
|
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
|
|
1319
|
+
log(' ' + gray(`Run 'ba-toolkit install --for ${agentId}' first.`));
|
|
876
1320
|
log('');
|
|
877
1321
|
return;
|
|
878
1322
|
}
|
|
879
1323
|
|
|
880
|
-
const
|
|
881
|
-
|
|
882
|
-
|
|
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
|
-
|
|
885
|
-
|
|
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
|
|
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: ${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
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(
|
|
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 ${
|
|
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
|
-
|
|
1003
|
-
log(' ' + green(`Removed ${
|
|
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
|
-
|
|
1579
|
+
readManifest,
|
|
1580
|
+
detectLegacyInstall,
|
|
1147
1581
|
renderAgentsMd,
|
|
1582
|
+
mergeAgentsMd,
|
|
1583
|
+
menuStep,
|
|
1584
|
+
renderMenu,
|
|
1148
1585
|
KNOWN_FLAGS,
|
|
1149
1586
|
DOMAINS,
|
|
1150
1587
|
AGENTS,
|