@kudusov.takhir/ba-toolkit 1.4.0 → 1.5.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 CHANGED
@@ -11,6 +11,33 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
11
11
 
12
12
  ---
13
13
 
14
+ ## [1.5.0] — 2026-04-08
15
+
16
+ ### Added
17
+
18
+ - **Typo detection on unknown CLI flags**, with a Levenshtein-based "Did you mean ...?" hint. Previously the parser silently accepted any `--flag` and stored it in `args.flags`, where most code paths then ignored it — users would hit `ba-toolkit init --drry-run`, get no error, and watch the script start an interactive session wondering why their flag did nothing. Now `validateFlags` runs immediately after parsing and rejects anything not in `KNOWN_FLAGS`:
19
+ ```
20
+ $ ba-toolkit init --drry-run
21
+ error: Unknown option: --drry-run
22
+ Did you mean --dry-run?
23
+ Run ba-toolkit --help for the full list of options.
24
+ ```
25
+ The Levenshtein threshold is calibrated (`max(1, floor(input.length / 3))`) so common typos like `--domian`, `--gloabl`, `--no-installl`, `--foo`/`--for` get matched, but unrelated inputs like `--foobar` (distance 3 from "global") get no suggestion at all — better to say nothing than suggest something wildly off.
26
+
27
+ ### Changed
28
+
29
+ - **Help output is ~20 lines shorter.** Four nearly-duplicate options sections (`INIT OPTIONS`, `INSTALL OPTIONS`, `UNINSTALL OPTIONS`, `UPGRADE OPTIONS`) collapsed into a single `OPTIONS` section. Each flag is listed exactly once with a per-command scope annotation (`init only — ...`, `install/uninstall/upgrade — ...`). Three sections after v1.4.0 added `uninstall`/`upgrade` repeated the same four flags with only wording differences; the dedup removes the noise without losing any information.
30
+ - **Domain and agent menu column widths are now computed dynamically** from the longest entry instead of being hardcoded to magic constants (`padEnd(13)` and `padEnd(20)`). If a future commit adds a domain or agent with a longer display name, the em-dash column on the right stays aligned automatically. No visible change for the current set.
31
+
32
+ ### Internal
33
+
34
+ - **`AGENTS.md` template extracted** to `skills/references/templates/agents-template.md`. Previously it lived as a 44-line embedded string in `bin/ba-toolkit.js`, which was the only artifact template not in `skills/references/templates/`. Now follows the same `[NAME]` / `[SLUG]` / `[DOMAIN]` / `[DATE]` placeholder convention as `brief-template.md`, `srs-template.md`, etc. `renderAgentsMd` shrinks from a 60-line string template to a 12-line file read + four `String.replace` calls. Adding a field to the auto-generated AGENTS.md is now a Markdown edit, not a JS edit.
35
+ - **`stringFlag(args, key)` helper extracted** to centralise the `flag && flag !== true` (and inverse `!flag || flag === true`) pattern that was repeated across `cmdInit` (4 sites), `cmdInstall`, `cmdUninstall`, `cmdUpgrade` (3 sites). Returns the string value or `null` for absent / boolean / empty-string flags. The seven call sites collapse to single-line `stringFlag(args, 'name')` reads.
36
+ - **`parseSkillFrontmatter(content)` mini-parser** replaces the three regexes that `skillToMdc` used to extract `name` and `description` from SKILL.md frontmatter. The previous folded-scalar regex (`description:\s*>\s*\r?\n([\s\S]*?)(?:\r?\n\w|$)`) used a fragile `\r?\n\w` lookahead that would have over-captured on multi-paragraph descriptions and silently misparsed the `|` literal block scalar form (no shipped SKILL.md uses it yet, but the trap was there). The new walker handles inline / folded / literal forms uniformly, recognises chomping indicators (`>+`/`>-`/`|+`/`|-`), and correctly collapses multi-paragraph blocks with blank lines. Backward-compat verified by running `ba-toolkit install --for cursor` and confirming all 21 generated `.mdc` files have the same descriptions as before.
37
+ - **Unit-test infrastructure (78 → 91 tests).** Pure helper functions (`sanitiseSlug`, `parseArgs`, `resolveDomain`, `resolveAgent`, `stringFlag`, `parseSkillFrontmatter`, `readSentinel`, `renderAgentsMd`, `levenshtein`, `closestMatch`) are now exported from `bin/ba-toolkit.js` (guarded by `require.main === module` so the CLI still runs directly), and covered by `test/cli.test.js` using Node's built-in `node:test` runner — zero added dependencies. The test file does not ship to npm consumers (`test/` is not in `package.json`'s `files` whitelist). Includes one integration test that loads every shipped `SKILL.md` and asserts the parser produces a non-empty single-line description for each — catches regressions where a future skill is added with a frontmatter form the parser doesn't understand.
38
+
39
+ ---
40
+
14
41
  ## [1.4.0] — 2026-04-08
15
42
 
16
43
  ### Added
@@ -267,7 +294,8 @@ CI scripts that relied on the old behaviour (`init` creates files only, `install
267
294
 
268
295
  ---
269
296
 
270
- [Unreleased]: https://github.com/TakhirKudusov/ba-toolkit/compare/v1.4.0...HEAD
297
+ [Unreleased]: https://github.com/TakhirKudusov/ba-toolkit/compare/v1.5.0...HEAD
298
+ [1.5.0]: https://github.com/TakhirKudusov/ba-toolkit/compare/v1.4.0...v1.5.0
271
299
  [1.4.0]: https://github.com/TakhirKudusov/ba-toolkit/compare/v1.3.2...v1.4.0
272
300
  [1.3.2]: https://github.com/TakhirKudusov/ba-toolkit/compare/v1.3.1...v1.3.2
273
301
  [1.3.1]: https://github.com/TakhirKudusov/ba-toolkit/compare/v1.3.0...v1.3.1
package/bin/ba-toolkit.js CHANGED
@@ -195,6 +195,103 @@ function resolveAgent(raw) {
195
195
  return AGENTS[trimmed] ? trimmed : null;
196
196
  }
197
197
 
198
+ // Returns the string value of a flag, or null if it's absent, was passed
199
+ // as a bare boolean (e.g. `--name` with no following value, which
200
+ // parseArgs stores as `true`), or has an empty value (e.g. `--name=`).
201
+ // Centralises the `flag && flag !== true` / `!flag || flag === true`
202
+ // pattern that was repeated across cmdInit, cmdInstall, cmdUninstall,
203
+ // cmdUpgrade.
204
+ function stringFlag(args, key) {
205
+ const v = args.flags[key];
206
+ return (typeof v === 'string' && v.length > 0) ? v : null;
207
+ }
208
+
209
+ // Every flag the CLI accepts. Typos that don't match anything in this
210
+ // set are rejected by validateFlags() with a "Did you mean ...?" hint.
211
+ // Single-letter aliases (-v, -h) are listed by their letter form.
212
+ const KNOWN_FLAGS = new Set([
213
+ 'name', 'slug', 'domain', 'for', 'no-install',
214
+ 'global', 'project', 'dry-run',
215
+ 'version', 'v', 'help', 'h',
216
+ ]);
217
+
218
+ // Levenshtein distance with the standard two-row optimisation. Used by
219
+ // closestMatch() to suggest a fix for unknown flags. Pure, exported for
220
+ // tests.
221
+ function levenshtein(a, b) {
222
+ if (a === b) return 0;
223
+ const m = a.length;
224
+ const n = b.length;
225
+ if (m === 0) return n;
226
+ if (n === 0) return m;
227
+ let prev = new Array(n + 1);
228
+ let curr = new Array(n + 1);
229
+ for (let j = 0; j <= n; j++) prev[j] = j;
230
+ for (let i = 1; i <= m; i++) {
231
+ curr[0] = i;
232
+ for (let j = 1; j <= n; j++) {
233
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
234
+ curr[j] = Math.min(
235
+ prev[j] + 1, // deletion
236
+ curr[j - 1] + 1, // insertion
237
+ prev[j - 1] + cost, // substitution
238
+ );
239
+ }
240
+ [prev, curr] = [curr, prev];
241
+ }
242
+ return prev[n];
243
+ }
244
+
245
+ // Find the candidate with the lowest Levenshtein distance to `input`,
246
+ // but only if the distance is small enough to be a plausible typo.
247
+ // Threshold scales with the input length: ~1 edit per 3 input chars,
248
+ // minimum 1. This catches the common cases (transposition like
249
+ // `--domian` for `--domain`, single insertion like `--drry-run` for
250
+ // `--dry-run`) without producing absurd suggestions for things that
251
+ // happen to share a few letters (`--foobar` should NOT suggest
252
+ // `--global` even though they have distance 3).
253
+ function closestMatch(input, candidates) {
254
+ if (!input) return null;
255
+ const threshold = Math.max(1, Math.floor(input.length / 3));
256
+ let best = null;
257
+ let bestDist = Infinity;
258
+ for (const c of candidates) {
259
+ const d = levenshtein(input, c);
260
+ if (d < bestDist && d <= threshold) {
261
+ best = c;
262
+ bestDist = d;
263
+ }
264
+ }
265
+ return best;
266
+ }
267
+
268
+ // Reject unknown flags with a helpful "Did you mean ...?" suggestion
269
+ // when one is plausible. Called from main() after parseArgs.
270
+ //
271
+ // This catches typos like `--drry-run` or `--for-claude-code` (instead
272
+ // of `--for claude-code`) that the previous version of the CLI would
273
+ // silently store and then ignore. Both cases used to leave the user
274
+ // staring at an interactive prompt wondering why their flag did
275
+ // nothing.
276
+ function validateFlags(args) {
277
+ const unknown = [];
278
+ for (const key of Object.keys(args.flags)) {
279
+ if (!KNOWN_FLAGS.has(key)) unknown.push(key);
280
+ }
281
+ if (unknown.length === 0) return;
282
+ for (const flag of unknown) {
283
+ const dashes = flag.length === 1 ? '-' : '--';
284
+ logError(`Unknown option: ${dashes}${flag}`);
285
+ const suggestion = closestMatch(flag, [...KNOWN_FLAGS]);
286
+ if (suggestion) {
287
+ const sugDashes = suggestion.length === 1 ? '-' : '--';
288
+ log(` Did you mean ${cyan(sugDashes + suggestion)}?`);
289
+ }
290
+ }
291
+ log('Run ' + cyan('ba-toolkit --help') + ' for the full list of options.');
292
+ process.exit(1);
293
+ }
294
+
198
295
  function today() {
199
296
  return new Date().toISOString().slice(0, 10);
200
297
  }
@@ -230,6 +327,79 @@ function copyDir(src, dest, { dryRun = false, transform = null } = {}) {
230
327
  return copied;
231
328
  }
232
329
 
330
+ // Minimal YAML frontmatter parser for SKILL.md files.
331
+ //
332
+ // Replaces the previous regex-based extraction, which used a fragile
333
+ // lookahead (`description:\s*>\s*\r?\n([\s\S]*?)(?:\r?\n\w|$)`) that
334
+ // didn't handle blank lines inside descriptions, didn't recognise the
335
+ // `|` literal block scalar form, and silently produced wrong output
336
+ // if the description happened to contain a line whose first character
337
+ // wasn't a word character.
338
+ //
339
+ // This is NOT a general YAML parser. It only handles the subset that
340
+ // SKILL.md files actually use:
341
+ // - Top-level keys at column 0: `key: value`
342
+ // - Inline scalar values: `name: foo`
343
+ // - Folded block scalars: `description: >` followed by indented text
344
+ // - Literal block scalars: `description: |` followed by indented text
345
+ // - Block chomping indicators (>+, >-, |+, |-)
346
+ // - Multi-paragraph block scalars with blank lines between paragraphs
347
+ //
348
+ // Unsupported (not used by any SKILL.md and YAGNI for the toolkit):
349
+ // - Nested mappings, sequences, anchors, aliases, tags
350
+ // - Quoted scalars (single or double quoted) — names would keep quotes
351
+ //
352
+ // Returns { name, description, body }. `description` is always
353
+ // flattened to a single line (whitespace collapsed) because the .mdc
354
+ // rule format expects a one-line description.
355
+ function parseSkillFrontmatter(content) {
356
+ const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
357
+ if (!fmMatch) {
358
+ return { name: null, description: '', body: content };
359
+ }
360
+ const frontmatter = fmMatch[1];
361
+ const body = fmMatch[2];
362
+ const lines = frontmatter.split(/\r?\n/);
363
+
364
+ const fields = {};
365
+ let currentKey = null;
366
+ let buffer = [];
367
+
368
+ const flush = () => {
369
+ if (currentKey !== null) {
370
+ fields[currentKey] = buffer.join(' ').replace(/\s+/g, ' ').trim();
371
+ }
372
+ currentKey = null;
373
+ buffer = [];
374
+ };
375
+
376
+ for (const line of lines) {
377
+ // A top-level YAML key starts at column 0 with `name:` style.
378
+ const m = line.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/);
379
+ if (m) {
380
+ flush();
381
+ currentKey = m[1];
382
+ const inlineValue = m[2].trim();
383
+ // Block scalar markers (>, |, >+, >-, |+, |-) introduce a block
384
+ // — they aren't part of the value themselves, so don't push them.
385
+ if (inlineValue && !/^[>|][+-]?$/.test(inlineValue)) {
386
+ buffer.push(inlineValue);
387
+ }
388
+ } else if (currentKey) {
389
+ // Continuation line for the active block scalar. Indentation and
390
+ // blank lines are folded into a single space by `flush()`.
391
+ buffer.push(line.trim());
392
+ }
393
+ }
394
+ flush();
395
+
396
+ return {
397
+ name: fields.name || null,
398
+ description: fields.description || '',
399
+ body,
400
+ };
401
+ }
402
+
233
403
  // Transform SKILL.md → .mdc for Cursor / Windsurf.
234
404
  // Other files (references/, templates/) are copied as-is.
235
405
  function skillToMdc(srcPath, destPath) {
@@ -238,90 +408,39 @@ function skillToMdc(srcPath, destPath) {
238
408
  return { destPath, content: fs.readFileSync(srcPath) };
239
409
  }
240
410
  const content = fs.readFileSync(srcPath, 'utf8');
241
- const fmMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
242
- let frontmatter = '';
243
- let body = content;
244
- if (fmMatch) {
245
- frontmatter = fmMatch[1];
246
- body = fmMatch[2];
247
- }
248
- const nameMatch = frontmatter.match(/^name:\s*(.+)$/m);
249
- // description is usually a multi-line block with `description: >`
250
- const descMatch = frontmatter.match(/description:\s*>\s*\r?\n([\s\S]*?)(?:\r?\n\w|$)/);
251
- const descInlineMatch = frontmatter.match(/^description:\s*(.+)$/m);
252
- const ruleName = nameMatch ? nameMatch[1].trim() : path.basename(path.dirname(srcPath));
253
- const rawDesc = descMatch ? descMatch[1] : (descInlineMatch ? descInlineMatch[1] : '');
254
- const ruleDesc = rawDesc.replace(/\s+/g, ' ').trim();
255
- const mdcFrontmatter = `---\ndescription: ${ruleDesc}\nalwaysApply: false\n---\n\n`;
411
+ const { name, description, body } = parseSkillFrontmatter(content);
412
+ const ruleName = name || path.basename(path.dirname(srcPath));
413
+ const mdcFrontmatter = `---\ndescription: ${description}\nalwaysApply: false\n---\n\n`;
256
414
  const newDestPath = path.join(path.dirname(destPath), `${ruleName}.mdc`);
257
415
  return { destPath: newDestPath, content: mdcFrontmatter + body };
258
416
  }
259
417
 
260
- // MAINTENANCE: when adding a new skill, update BOTH tables below.
418
+ // Path to the AGENTS.md template file. Lives next to the rest of the
419
+ // reference templates so the convention stays uniform: anything that
420
+ // looks like generated artifact content lives in
421
+ // skills/references/templates/, not embedded as a string in the CLI.
422
+ //
423
+ // MAINTENANCE: when adding a new skill, update both tables in
424
+ // agents-template.md:
261
425
  // - Sequential pipeline stages (numbered 0-11 + 7a sub-step) go in
262
426
  // "Pipeline Status" — they have a per-project status that progresses.
263
427
  // - Cross-cutting utilities (no fixed stage) go in "Cross-cutting Tools".
264
428
  // The README.md pipeline table is the canonical source of truth for the
265
- // 21-skill list and ordering; keep this template in sync with it.
266
- function renderAgentsMd({ name, slug, domain }) {
267
- return `# BA Toolkit — Project Context
268
-
269
- > Auto-generated by \`ba-toolkit init\` on ${today()}. Updated automatically by /brief and /srs.
270
-
271
- ## Active Project
272
-
273
- **Project:** ${name}
274
- **Slug:** ${slug}
275
- **Domain:** ${domain}
276
- **Language:** English
277
- **Output folder:** output/${slug}/
278
-
279
- ## Pipeline Status
280
-
281
- | Stage | Skill | Status | File |
282
- |-------|-------|--------|------|
283
- | 0 | /principles | ⬜ Not started | — |
284
- | 1 | /brief | ⬜ Not started | — |
285
- | 2 | /srs | ⬜ Not started | — |
286
- | 3 | /stories | ⬜ Not started | — |
287
- | 4 | /usecases | ⬜ Not started | — |
288
- | 5 | /ac | ⬜ Not started | — |
289
- | 6 | /nfr | ⬜ Not started | — |
290
- | 7 | /datadict | ⬜ Not started | — |
291
- | 7a | /research | ⬜ Not started | — |
292
- | 8 | /apicontract | ⬜ Not started | — |
293
- | 9 | /wireframes | ⬜ Not started | — |
294
- | 10 | /scenarios | ⬜ Not started | — |
295
- | 11 | /handoff | ⬜ Not started | — |
296
-
297
- ## Cross-cutting Tools
298
-
299
- Utilities available throughout the pipeline. No fixed stage — invoke whenever they help. See README.md for the prerequisites of each.
429
+ // 21-skill list and ordering; keep that template in sync with it.
430
+ const AGENTS_TEMPLATE_PATH = path.join(SKILLS_DIR, 'references', 'templates', 'agents-template.md');
300
431
 
301
- | Tool | Purpose |
302
- |------|---------|
303
- | /trace | Traceability Matrix + coverage gaps |
304
- | /clarify [focus] | Targeted ambiguity resolution for any artifact |
305
- | /analyze | Cross-artifact quality report with severity-rated findings |
306
- | /estimate | Effort estimation Fibonacci SP, T-shirt sizes, or person-days |
307
- | /glossary | Unified project glossary with terminology drift detection |
308
- | /export [format] | Export User Stories to Jira / GitHub Issues / Linear / CSV |
309
- | /risk | Risk register — probability × impact matrix, mitigation per risk |
310
- | /sprint | Sprint plan — stories grouped by velocity and capacity with sprint goals |
311
-
312
- ## Key Constraints
313
-
314
- - Domain: ${domain}
315
- - (Add constraints after /brief completes)
316
-
317
- ## Key Stakeholder Roles
318
-
319
- - (Populated after /srs completes)
320
-
321
- ## Open Questions
322
-
323
- - (None yet)
324
- `;
432
+ function renderAgentsMd({ name, slug, domain }) {
433
+ let template;
434
+ try {
435
+ template = fs.readFileSync(AGENTS_TEMPLATE_PATH, 'utf8');
436
+ } catch (err) {
437
+ throw new Error(`Failed to read AGENTS.md template at ${AGENTS_TEMPLATE_PATH}: ${err.message}`);
438
+ }
439
+ return template
440
+ .replace(/\[NAME\]/g, name)
441
+ .replace(/\[SLUG\]/g, slug)
442
+ .replace(/\[DOMAIN\]/g, domain)
443
+ .replace(/\[DATE\]/g, today());
325
444
  }
326
445
 
327
446
  // --- Commands ----------------------------------------------------------
@@ -333,8 +452,8 @@ async function cmdInit(args) {
333
452
  log('');
334
453
 
335
454
  // --- 1. Project name (slug derives from it) ---
336
- const nameFromFlag = !!(args.flags.name && args.flags.name !== true);
337
- let name = nameFromFlag ? args.flags.name : null;
455
+ const nameFlag = stringFlag(args, 'name');
456
+ let name = nameFlag;
338
457
  if (!name) name = await prompt(' Project name (e.g. My App): ');
339
458
  name = String(name || '').trim();
340
459
  if (!name) {
@@ -344,11 +463,11 @@ async function cmdInit(args) {
344
463
 
345
464
  // --- 2. Slug (auto-derived from name; confirmed interactively unless
346
465
  // both --name and --slug were passed on the command line) ---
347
- const slugFromFlag = !!(args.flags.slug && args.flags.slug !== true);
348
- let slug = slugFromFlag ? args.flags.slug : null;
466
+ const slugFlag = stringFlag(args, 'slug');
467
+ let slug = slugFlag;
349
468
  if (!slug) {
350
469
  const derived = sanitiseSlug(name);
351
- if (nameFromFlag) {
470
+ if (nameFlag) {
352
471
  // Non-interactive path. Either accept the derived slug, or fail
353
472
  // loudly with a hint when the name has no ASCII letters/digits to
354
473
  // derive from (e.g. `--name "Проект"` or `--name "🚀"`). Without
@@ -375,20 +494,22 @@ async function cmdInit(args) {
375
494
  }
376
495
 
377
496
  // --- 3. Domain (numbered menu) ---
378
- let domain = args.flags.domain;
379
- if (domain && domain !== true) {
380
- domain = resolveDomain(String(domain));
497
+ const domainFlag = stringFlag(args, 'domain');
498
+ let domain;
499
+ if (domainFlag) {
500
+ domain = resolveDomain(domainFlag);
381
501
  if (!domain) {
382
- logError(`Unknown domain: ${args.flags.domain}`);
502
+ logError(`Unknown domain: ${domainFlag}`);
383
503
  log('Valid ids: ' + DOMAINS.map((d) => d.id).join(', '));
384
504
  process.exit(1);
385
505
  }
386
506
  } else {
387
507
  log('');
388
508
  log(' ' + yellow('Pick a domain:'));
509
+ const domainNameWidth = Math.max(...DOMAINS.map((d) => d.name.length));
389
510
  DOMAINS.forEach((d, i) => {
390
511
  const idx = String(i + 1).padStart(2);
391
- log(` ${idx}) ${bold(d.name.padEnd(13))} ${gray('— ' + d.desc)}`);
512
+ log(` ${idx}) ${bold(d.name.padEnd(domainNameWidth))} ${gray('— ' + d.desc)}`);
392
513
  });
393
514
  log('');
394
515
  const raw = await prompt(` Select [1-${DOMAINS.length}]: `);
@@ -401,12 +522,13 @@ async function cmdInit(args) {
401
522
 
402
523
  // --- 4. Agent (numbered menu), unless --no-install ---
403
524
  const skipInstall = !!args.flags['no-install'];
404
- let agentId = args.flags.for;
525
+ const forFlag = stringFlag(args, 'for');
526
+ let agentId = null;
405
527
  if (!skipInstall) {
406
- if (agentId && agentId !== true) {
407
- agentId = resolveAgent(String(agentId));
528
+ if (forFlag) {
529
+ agentId = resolveAgent(forFlag);
408
530
  if (!agentId) {
409
- logError(`Unknown agent: ${args.flags.for}`);
531
+ logError(`Unknown agent: ${forFlag}`);
410
532
  log('Supported: ' + Object.keys(AGENTS).join(', '));
411
533
  process.exit(1);
412
534
  }
@@ -414,9 +536,10 @@ async function cmdInit(args) {
414
536
  log('');
415
537
  log(' ' + yellow('Pick your AI agent:'));
416
538
  const agentEntries = Object.entries(AGENTS);
539
+ const agentNameWidth = Math.max(...agentEntries.map(([, a]) => a.name.length));
417
540
  agentEntries.forEach(([id, a], i) => {
418
541
  const idx = String(i + 1).padStart(2);
419
- log(` ${idx}) ${bold(a.name.padEnd(20))} ${gray('(' + id + ')')}`);
542
+ log(` ${idx}) ${bold(a.name.padEnd(agentNameWidth))} ${gray('(' + id + ')')}`);
420
543
  });
421
544
  log('');
422
545
  const raw = await prompt(` Select [1-${agentEntries.length}]: `);
@@ -596,8 +719,8 @@ async function runInstall({ agentId, isGlobal, isProject, dryRun, showHeader = t
596
719
  }
597
720
 
598
721
  async function cmdInstall(args) {
599
- const agentId = args.flags.for;
600
- if (!agentId || agentId === true) {
722
+ const agentId = stringFlag(args, 'for');
723
+ if (!agentId) {
601
724
  logError('--for <agent> is required.');
602
725
  log('Supported agents: ' + Object.keys(AGENTS).join(', '));
603
726
  process.exit(1);
@@ -726,8 +849,8 @@ function cmdStatus() {
726
849
  }
727
850
 
728
851
  async function cmdUpgrade(args) {
729
- const agentId = args.flags.for;
730
- if (!agentId || agentId === true) {
852
+ const agentId = stringFlag(args, 'for');
853
+ if (!agentId) {
731
854
  logError('--for <agent> is required.');
732
855
  log('Supported agents: ' + Object.keys(AGENTS).join(', '));
733
856
  process.exit(1);
@@ -813,8 +936,8 @@ async function cmdUpgrade(args) {
813
936
  }
814
937
 
815
938
  async function cmdUninstall(args) {
816
- const agentId = args.flags.for;
817
- if (!agentId || agentId === true) {
939
+ const agentId = stringFlag(args, 'for');
940
+ if (!agentId) {
818
941
  logError('--for <agent> is required.');
819
942
  log('Supported agents: ' + Object.keys(AGENTS).join(', '));
820
943
  process.exit(1);
@@ -908,38 +1031,24 @@ ${bold('COMMANDS')}
908
1031
  report which versions are installed where.
909
1032
  Read-only; no flags.
910
1033
 
911
- ${bold('INIT OPTIONS')}
912
- --name <name> Skip the project name prompt
913
- --slug <slug> Skip the slug prompt (auto-derived from name)
914
- --domain <id> Skip the domain menu (e.g. saas, fintech)
915
- --for <agent> Skip the agent menu (e.g. claude-code)
916
- --no-install Create the project structure only; don't
917
- install skills. Useful for CI or when you
918
- want to pick the agent later.
919
- --global Install agent skills user-wide
920
- --project Install agent skills project-level (default
921
- when the agent supports it)
922
- --dry-run Preview the install step without writing
923
-
924
- ${bold('INSTALL OPTIONS')}
925
- --for <agent> One of: ${Object.keys(AGENTS).join(', ')}
926
- --global User-wide install
927
- --project Project-level install (default when supported)
928
- --dry-run Preview without writing files
929
-
930
- ${bold('UNINSTALL OPTIONS')}
931
- --for <agent> One of: ${Object.keys(AGENTS).join(', ')}
932
- --global Remove the user-wide install
933
- --project Remove the project-level install
934
- (default when the agent supports it)
935
- --dry-run Preview without removing files
936
-
937
- ${bold('UPGRADE OPTIONS')}
938
- --for <agent> One of: ${Object.keys(AGENTS).join(', ')}
939
- --global Upgrade the user-wide install
940
- --project Upgrade the project-level install
941
- (default when the agent supports it)
942
- --dry-run Preview without writing or removing files
1034
+ ${bold('OPTIONS')}
1035
+ --name <name> init only — skip the project name prompt
1036
+ --slug <slug> init only — skip the slug prompt (auto-derived
1037
+ from name)
1038
+ --domain <id> init only — skip the domain menu
1039
+ (e.g. saas, fintech)
1040
+ --no-install init only create the project structure
1041
+ without installing skills (useful for CI)
1042
+ --for <agent> install/uninstall/upgrade pick the target
1043
+ agent. One of: ${Object.keys(AGENTS).join(', ')}.
1044
+ init also accepts this to skip the agent menu.
1045
+ --global install/uninstall/upgrade target the
1046
+ user-wide install
1047
+ --project install/uninstall/upgrade — target the
1048
+ project-level install (default when the
1049
+ agent supports it)
1050
+ --dry-run init/install/uninstall/upgrade preview
1051
+ without writing or removing files
943
1052
 
944
1053
  ${bold('GENERAL OPTIONS')}
945
1054
  --version, -v Print version and exit
@@ -980,6 +1089,7 @@ ${bold('LEARN MORE')}
980
1089
 
981
1090
  async function main() {
982
1091
  const args = parseArgs(process.argv.slice(2));
1092
+ validateFlags(args);
983
1093
 
984
1094
  if (args.flags.version || args.flags.v) {
985
1095
  log(PKG.version);
@@ -1019,26 +1129,49 @@ async function main() {
1019
1129
  }
1020
1130
  }
1021
1131
 
1022
- // Clean exit on Ctrl+C: print on a fresh line so we don't append to a
1023
- // half-typed prompt, close the readline interface so the terminal is
1024
- // returned to a sane state, then exit with the conventional 130 code.
1025
- process.on('SIGINT', () => {
1026
- console.log('\n ' + yellow('Cancelled.'));
1027
- closeReadline();
1028
- process.exit(130);
1029
- });
1030
-
1031
- main()
1032
- .then(() => {
1033
- closeReadline();
1034
- })
1035
- .catch((err) => {
1132
+ // Exports for tests. Pure functions only anything that prompts or
1133
+ // touches the filesystem stays internal. The bin file is still
1134
+ // runnable as a CLI; the `require.main === module` guard below
1135
+ // prevents `main()` from firing when the file is loaded as a module
1136
+ // from the test runner.
1137
+ module.exports = {
1138
+ sanitiseSlug,
1139
+ parseArgs,
1140
+ resolveDomain,
1141
+ resolveAgent,
1142
+ stringFlag,
1143
+ levenshtein,
1144
+ closestMatch,
1145
+ parseSkillFrontmatter,
1146
+ readSentinel,
1147
+ renderAgentsMd,
1148
+ KNOWN_FLAGS,
1149
+ DOMAINS,
1150
+ AGENTS,
1151
+ };
1152
+
1153
+ if (require.main === module) {
1154
+ // Clean exit on Ctrl+C: print on a fresh line so we don't append to a
1155
+ // half-typed prompt, close the readline interface so the terminal is
1156
+ // returned to a sane state, then exit with the conventional 130 code.
1157
+ process.on('SIGINT', () => {
1158
+ console.log('\n ' + yellow('Cancelled.'));
1036
1159
  closeReadline();
1037
- if (err && err.code === 'INPUT_CLOSED') {
1038
- logError('Input stream closed before all prompts could be answered.');
1039
- log('Pass remaining values as flags (e.g. --name, --domain, --for) or run interactively.');
1040
- process.exit(1);
1041
- }
1042
- logError(err && (err.stack || err.message) || String(err));
1043
- process.exit(1);
1160
+ process.exit(130);
1044
1161
  });
1162
+
1163
+ main()
1164
+ .then(() => {
1165
+ closeReadline();
1166
+ })
1167
+ .catch((err) => {
1168
+ closeReadline();
1169
+ if (err && err.code === 'INPUT_CLOSED') {
1170
+ logError('Input stream closed before all prompts could be answered.');
1171
+ log('Pass remaining values as flags (e.g. --name, --domain, --for) or run interactively.');
1172
+ process.exit(1);
1173
+ }
1174
+ logError(err && (err.stack || err.message) || String(err));
1175
+ process.exit(1);
1176
+ });
1177
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudusov.takhir/ba-toolkit",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "AI-powered Business Analyst pipeline — 21 skills from project brief to development handoff. Works with Claude Code, Codex CLI, Gemini CLI, Cursor, and Windsurf.",
5
5
  "keywords": [
6
6
  "business-analyst",
@@ -43,6 +43,6 @@
43
43
  "node": ">=18"
44
44
  },
45
45
  "scripts": {
46
- "test": "node bin/ba-toolkit.js --help > /dev/null && echo CLI smoke test passed"
46
+ "test": "node --test test/cli.test.js"
47
47
  }
48
48
  }
@@ -0,0 +1,57 @@
1
+ # BA Toolkit — Project Context
2
+
3
+ > Auto-generated by `ba-toolkit init` on [DATE]. Updated automatically by /brief and /srs.
4
+
5
+ ## Active Project
6
+
7
+ **Project:** [NAME]
8
+ **Slug:** [SLUG]
9
+ **Domain:** [DOMAIN]
10
+ **Language:** English
11
+ **Output folder:** output/[SLUG]/
12
+
13
+ ## Pipeline Status
14
+
15
+ | Stage | Skill | Status | File |
16
+ |-------|-------|--------|------|
17
+ | 0 | /principles | ⬜ Not started | — |
18
+ | 1 | /brief | ⬜ Not started | — |
19
+ | 2 | /srs | ⬜ Not started | — |
20
+ | 3 | /stories | ⬜ Not started | — |
21
+ | 4 | /usecases | ⬜ Not started | — |
22
+ | 5 | /ac | ⬜ Not started | — |
23
+ | 6 | /nfr | ⬜ Not started | — |
24
+ | 7 | /datadict | ⬜ Not started | — |
25
+ | 7a | /research | ⬜ Not started | — |
26
+ | 8 | /apicontract | ⬜ Not started | — |
27
+ | 9 | /wireframes | ⬜ Not started | — |
28
+ | 10 | /scenarios | ⬜ Not started | — |
29
+ | 11 | /handoff | ⬜ Not started | — |
30
+
31
+ ## Cross-cutting Tools
32
+
33
+ Utilities available throughout the pipeline. No fixed stage — invoke whenever they help. See README.md for the prerequisites of each.
34
+
35
+ | Tool | Purpose |
36
+ |------|---------|
37
+ | /trace | Traceability Matrix + coverage gaps |
38
+ | /clarify [focus] | Targeted ambiguity resolution for any artifact |
39
+ | /analyze | Cross-artifact quality report with severity-rated findings |
40
+ | /estimate | Effort estimation — Fibonacci SP, T-shirt sizes, or person-days |
41
+ | /glossary | Unified project glossary with terminology drift detection |
42
+ | /export [format] | Export User Stories to Jira / GitHub Issues / Linear / CSV |
43
+ | /risk | Risk register — probability × impact matrix, mitigation per risk |
44
+ | /sprint | Sprint plan — stories grouped by velocity and capacity with sprint goals |
45
+
46
+ ## Key Constraints
47
+
48
+ - Domain: [DOMAIN]
49
+ - (Add constraints after /brief completes)
50
+
51
+ ## Key Stakeholder Roles
52
+
53
+ - (Populated after /srs completes)
54
+
55
+ ## Open Questions
56
+
57
+ - (None yet)