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