@kudusov.takhir/ba-toolkit 1.3.2 → 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,54 @@ 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
+
41
+ ## [1.4.0] — 2026-04-08
42
+
43
+ ### Added
44
+
45
+ - **`ba-toolkit uninstall --for <agent>`** — remove BA Toolkit skills from an agent's directory. Symmetric to `install`: same `--for`, `--global`, `--project`, `--dry-run` flag set, same project-vs-global default. Counts files in the destination, asks `Remove {dest}? (y/N)` (defaults to N), and prints `Removed N files` after the rm completes. The pre-removal safety guard refuses to proceed unless `path.basename(destDir) === 'ba-toolkit'` — this is the only place in the CLI that calls `fs.rmSync({recursive: true})`, so it gets the strictest validation against future bugs that could turn it into `rm -rf $HOME`.
46
+ - **`ba-toolkit upgrade --for <agent>`** (aliased as `update`) — refresh skills after a toolkit version bump. Reads the new version sentinel (see below), compares to `PKG.version`, and either prints `Already up to date` or wipes the destination wholesale and re-runs install with `force: true` (skipping the overwrite prompt). The wipe-and-reinstall approach guarantees that files removed from the toolkit between versions don't linger as ghost files in the destination — fixes the same class of bug that motivated `cmdUninstall`'s safety check. Pre-1.4 installs with no sentinel are treated as out-of-date and get a clean reinstall on first upgrade.
47
+ - **`ba-toolkit status`** — pure read-only inspection: scans every (agent × scope) combination — 5 agents × project/global where supported, 8 real locations in total — and reports which versions of BA Toolkit are installed where. Output is grouped per installation, multi-line for readability, with colored version labels (`green: current` / `yellow: outdated` / `gray: pre-1.4 install with no sentinel`) and a summary footer pointing at `upgrade` for stale installs. Drives the natural follow-up to the version sentinel: now there's something to read it back with.
48
+ - **Version sentinel `.ba-toolkit-version`** — `runInstall` now writes a hidden JSON marker file (`{"version": "1.4.0", "installedAt": "..."}`) into the install destination after a successful copy. The file has no `.md`/`.mdc` extension, so all five supported agents' skill loaders ignore it. Lets `upgrade` and `status` tell which package version is currently installed without diffing every file.
49
+ - **`runInstall({..., force: true})` option** — skips the existing-destination overwrite prompt. Used by `cmdUpgrade` because it has already wiped the destination (or is in dry-run) and the prompt would just be noise. Not exposed as a CLI flag — `force` is an internal API surface only.
50
+
51
+ ### Fixed
52
+
53
+ - **Five Tier 1 CLI fixes were already shipped in 1.3.2**, but they're worth restating in context here because 1.4.0 builds directly on the same `bin/ba-toolkit.js` improvements: `cmdInit` now honours `runInstall`'s return value (no false success message after a declined overwrite), `parseArgs` accepts `--key=value` form, the readline lifecycle and SIGINT handling are unified through a single shared interface with `INPUT_CLOSED` rejection, the slug-derivation path prints a clear error when the input has no ASCII letters, and `AGENTS.md` now lists all 21 skills (the previous template was missing the 8 cross-cutting utilities added in v1.1 and v1.2). See the 1.3.2 entry below for the per-fix detail.
54
+
55
+ ### Internal
56
+
57
+ - **`resolveAgentDestination(...)` helper** — extracted scope-resolution logic from `runInstall`'s inline checks. `cmdUninstall` and `cmdUpgrade` reuse it. `runInstall` itself could be refactored to call the helper too in a follow-up — kept inline for now to keep this release's diff focused on the user-facing features.
58
+ - **Documentation:** `CLAUDE.md` added at the repo root, with project conventions, the release flow (including the `.claude/settings.local.json` stash dance), the npm publish CI gotchas (curl tarball bypass for the broken bundled npm, `_authToken` strip for OIDC), and the do-not-touch list. Future Claude Code sessions get the institutional context without reading the full git log.
59
+
60
+ ---
61
+
14
62
  ## [1.3.2] — 2026-04-08
15
63
 
16
64
  ### Fixed
@@ -246,7 +294,9 @@ CI scripts that relied on the old behaviour (`init` creates files only, `install
246
294
 
247
295
  ---
248
296
 
249
- [Unreleased]: https://github.com/TakhirKudusov/ba-toolkit/compare/v1.3.2...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
299
+ [1.4.0]: https://github.com/TakhirKudusov/ba-toolkit/compare/v1.3.2...v1.4.0
250
300
  [1.3.2]: https://github.com/TakhirKudusov/ba-toolkit/compare/v1.3.1...v1.3.2
251
301
  [1.3.1]: https://github.com/TakhirKudusov/ba-toolkit/compare/v1.3.0...v1.3.1
252
302
  [1.3.0]: https://github.com/TakhirKudusov/ba-toolkit/compare/v1.2.5...v1.3.0
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.
300
-
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)
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');
316
431
 
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}]: `);
@@ -497,10 +620,39 @@ async function cmdInit(args) {
497
620
  log('');
498
621
  }
499
622
 
500
- // Core install logic. Shared between `cmdInstall` (standalone) and `cmdInit`
501
- // (full setup). Returns true on success, false if the user declined to
502
- // overwrite an existing destination.
503
- async function runInstall({ agentId, isGlobal, isProject, dryRun, showHeader = true }) {
623
+ // Marker file written into the install destination after a successful copy.
624
+ // Lets `upgrade` and `status` (future command) tell which package version
625
+ // is currently installed without diffing every file. Hidden file with no
626
+ // `.md` / `.mdc` extension so the agent's skill loader ignores it.
627
+ const SENTINEL_FILENAME = '.ba-toolkit-version';
628
+
629
+ function readSentinel(destDir) {
630
+ const p = path.join(destDir, SENTINEL_FILENAME);
631
+ if (!fs.existsSync(p)) return null;
632
+ try {
633
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
634
+ } catch {
635
+ return null;
636
+ }
637
+ }
638
+
639
+ function writeSentinel(destDir) {
640
+ const payload = {
641
+ version: PKG.version,
642
+ installedAt: new Date().toISOString(),
643
+ };
644
+ fs.writeFileSync(
645
+ path.join(destDir, SENTINEL_FILENAME),
646
+ JSON.stringify(payload, null, 2) + '\n',
647
+ );
648
+ }
649
+
650
+ // Core install logic. Shared between `cmdInstall` (standalone), `cmdInit`
651
+ // (full setup), and `cmdUpgrade`. Returns true on success, false if the
652
+ // user declined to overwrite an existing destination. Pass `force: true`
653
+ // to skip the overwrite prompt — `cmdUpgrade` uses this because it has
654
+ // already wiped the destination and explicitly knows the overwrite is ok.
655
+ async function runInstall({ agentId, isGlobal, isProject, dryRun, showHeader = true, force = false }) {
504
656
  const agent = AGENTS[agentId];
505
657
  if (!agent) {
506
658
  logError(`Unknown agent: ${agentId}`);
@@ -538,7 +690,7 @@ async function runInstall({ agentId, isGlobal, isProject, dryRun, showHeader = t
538
690
  log(` format: ${agent.format === 'mdc' ? '.mdc (converted from SKILL.md)' : 'SKILL.md (native)'}`);
539
691
  if (dryRun) log(' ' + yellow('mode: dry-run (no files will be written)'));
540
692
 
541
- if (fs.existsSync(destDir) && !dryRun) {
693
+ if (fs.existsSync(destDir) && !dryRun && !force) {
542
694
  const answer = await prompt(` ${destDir} already exists. Overwrite? (y/N): `);
543
695
  if (answer.toLowerCase() !== 'y') {
544
696
  log(' cancelled.');
@@ -555,6 +707,10 @@ async function runInstall({ agentId, isGlobal, isProject, dryRun, showHeader = t
555
707
  process.exit(1);
556
708
  }
557
709
 
710
+ if (!dryRun) {
711
+ writeSentinel(destDir);
712
+ }
713
+
558
714
  log(' ' + green(`${dryRun ? 'would copy' : 'copied'} ${copied.length} files.`));
559
715
  if (!dryRun && agent.format === 'mdc') {
560
716
  log(' ' + gray('SKILL.md files converted to .mdc rule format.'));
@@ -563,8 +719,8 @@ async function runInstall({ agentId, isGlobal, isProject, dryRun, showHeader = t
563
719
  }
564
720
 
565
721
  async function cmdInstall(args) {
566
- const agentId = args.flags.for;
567
- if (!agentId || agentId === true) {
722
+ const agentId = stringFlag(args, 'for');
723
+ if (!agentId) {
568
724
  logError('--for <agent> is required.');
569
725
  log('Supported agents: ' + Object.keys(AGENTS).join(', '));
570
726
  process.exit(1);
@@ -584,6 +740,271 @@ async function cmdInstall(args) {
584
740
  log('');
585
741
  }
586
742
 
743
+ // Resolve agent + scope (project vs global) into the target directory
744
+ // path. Shared validation for cmdUninstall — `runInstall` does its own
745
+ // version of this inline; both could be unified later.
746
+ function resolveAgentDestination({ agentId, isGlobal, isProject }) {
747
+ const agent = AGENTS[agentId];
748
+ if (!agent) {
749
+ logError(`Unknown agent: ${agentId}`);
750
+ log('Supported: ' + Object.keys(AGENTS).join(', '));
751
+ process.exit(1);
752
+ }
753
+ let effectiveGlobal = !!isGlobal;
754
+ if (!isGlobal && !isProject) {
755
+ effectiveGlobal = !agent.projectPath;
756
+ }
757
+ if (effectiveGlobal && !agent.globalPath) {
758
+ logError(`${agent.name} does not support --global install.`);
759
+ process.exit(1);
760
+ }
761
+ if (!effectiveGlobal && !agent.projectPath) {
762
+ logError(`${agent.name} does not support project-level install. Use --global.`);
763
+ process.exit(1);
764
+ }
765
+ const destDir = effectiveGlobal ? agent.globalPath : path.resolve(process.cwd(), agent.projectPath);
766
+ return { agent, destDir, effectiveGlobal };
767
+ }
768
+
769
+ function cmdStatus() {
770
+ log('');
771
+ log(' ' + cyan('BA Toolkit — Installation Status'));
772
+ log(' ' + cyan('================================'));
773
+ log('');
774
+ log(` package version: ${PKG.version}`);
775
+ log(` scanning from: ${process.cwd()}`);
776
+ log('');
777
+
778
+ // Walk every (agent × scope) combination and collect the ones whose
779
+ // destination directory actually exists. Project-scope paths resolve
780
+ // against the current working directory; global paths are absolute.
781
+ const rows = [];
782
+ for (const [agentId, agent] of Object.entries(AGENTS)) {
783
+ if (agent.projectPath) {
784
+ const projectDir = path.resolve(process.cwd(), agent.projectPath);
785
+ if (fs.existsSync(projectDir)) {
786
+ const sentinel = readSentinel(projectDir);
787
+ rows.push({
788
+ agentName: agent.name,
789
+ agentId,
790
+ scope: 'project',
791
+ path: projectDir,
792
+ version: sentinel ? sentinel.version : null,
793
+ installedAt: sentinel ? sentinel.installedAt : null,
794
+ });
795
+ }
796
+ }
797
+ if (agent.globalPath) {
798
+ if (fs.existsSync(agent.globalPath)) {
799
+ const sentinel = readSentinel(agent.globalPath);
800
+ rows.push({
801
+ agentName: agent.name,
802
+ agentId,
803
+ scope: 'global',
804
+ path: agent.globalPath,
805
+ version: sentinel ? sentinel.version : null,
806
+ installedAt: sentinel ? sentinel.installedAt : null,
807
+ });
808
+ }
809
+ }
810
+ }
811
+
812
+ if (rows.length === 0) {
813
+ log(' ' + gray('No BA Toolkit installations found in any known location.'));
814
+ log(' ' + gray("Run 'ba-toolkit install --for <agent>' to install one."));
815
+ log('');
816
+ return;
817
+ }
818
+
819
+ log(` Found ${bold(rows.length)} installation${rows.length === 1 ? '' : 's'}:`);
820
+ log('');
821
+
822
+ for (const row of rows) {
823
+ let versionLabel;
824
+ if (!row.version) {
825
+ versionLabel = gray('(unknown — pre-1.4 install with no sentinel)');
826
+ } else if (row.version === PKG.version) {
827
+ versionLabel = green(row.version + ' (current)');
828
+ } else {
829
+ versionLabel = yellow(row.version + ' (outdated)');
830
+ }
831
+ log(` ${bold(row.agentName)} ${gray('(' + row.agentId + ', ' + row.scope + ')')}`);
832
+ log(` path: ${row.path}`);
833
+ log(` version: ${versionLabel}`);
834
+ if (row.installedAt) {
835
+ log(` installed: ${gray(row.installedAt)}`);
836
+ }
837
+ log('');
838
+ }
839
+
840
+ const stale = rows.filter((r) => !r.version || r.version !== PKG.version);
841
+ if (stale.length > 0) {
842
+ log(' ' + yellow(`${stale.length} installation${stale.length === 1 ? '' : 's'} not at version ${PKG.version}.`));
843
+ log(' ' + gray("Run 'ba-toolkit upgrade --for <agent>' to refresh."));
844
+ log('');
845
+ } else {
846
+ log(' ' + green('All installations are up to date.'));
847
+ log('');
848
+ }
849
+ }
850
+
851
+ async function cmdUpgrade(args) {
852
+ const agentId = stringFlag(args, 'for');
853
+ if (!agentId) {
854
+ logError('--for <agent> is required.');
855
+ log('Supported agents: ' + Object.keys(AGENTS).join(', '));
856
+ process.exit(1);
857
+ }
858
+ const { agent, destDir, effectiveGlobal } = resolveAgentDestination({
859
+ agentId,
860
+ isGlobal: !!args.flags.global,
861
+ isProject: !!args.flags.project,
862
+ });
863
+ const dryRun = !!args.flags['dry-run'];
864
+
865
+ log('');
866
+ log(' ' + cyan(`BA Toolkit — Upgrade for ${agent.name}`));
867
+ log(' ' + cyan('================================'));
868
+ log('');
869
+ log(` destination: ${destDir}`);
870
+ log(` scope: ${effectiveGlobal ? 'global (user-wide)' : 'project-level'}`);
871
+
872
+ if (!fs.existsSync(destDir)) {
873
+ log('');
874
+ log(' ' + gray(`No installation found at ${destDir}.`));
875
+ log(' ' + gray(`Run \`ba-toolkit install --for ${agentId}\` first.`));
876
+ log('');
877
+ return;
878
+ }
879
+
880
+ const sentinel = readSentinel(destDir);
881
+ const currentVersion = PKG.version;
882
+ const installedVersion = sentinel ? sentinel.version : null;
883
+
884
+ if (installedVersion === currentVersion) {
885
+ log(` installed: ${installedVersion} (current)`);
886
+ log(` package: ${currentVersion}`);
887
+ log('');
888
+ log(' ' + green('Already up to date.'));
889
+ log(' ' + gray(`To force a clean reinstall, run \`ba-toolkit install --for ${agentId}\`.`));
890
+ log('');
891
+ return;
892
+ }
893
+
894
+ log(` installed: ${installedVersion || gray('(unknown — pre-1.4 install with no sentinel)')}`);
895
+ log(` package: ${currentVersion}`);
896
+ if (dryRun) log(' ' + yellow('mode: dry-run (no files will be written)'));
897
+ log('');
898
+
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
+ if (dryRun) {
908
+ let existingCount = 0;
909
+ (function walk(d) {
910
+ for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
911
+ const p = path.join(d, entry.name);
912
+ if (entry.isDirectory()) walk(p);
913
+ else existingCount++;
914
+ }
915
+ })(destDir);
916
+ log(' ' + yellow(`would remove ${existingCount} existing files`));
917
+ } else {
918
+ log(' ' + green('Removing previous install...'));
919
+ fs.rmSync(destDir, { recursive: true, force: true });
920
+ }
921
+
922
+ const ok = await runInstall({
923
+ agentId,
924
+ isGlobal: effectiveGlobal,
925
+ isProject: !effectiveGlobal,
926
+ dryRun,
927
+ showHeader: false,
928
+ force: true,
929
+ });
930
+ log('');
931
+ if (ok && !dryRun) {
932
+ log(' ' + cyan(`Upgraded to ${currentVersion}.`));
933
+ log(' ' + yellow(agent.restartHint));
934
+ }
935
+ log('');
936
+ }
937
+
938
+ async function cmdUninstall(args) {
939
+ const agentId = stringFlag(args, 'for');
940
+ if (!agentId) {
941
+ logError('--for <agent> is required.');
942
+ log('Supported agents: ' + Object.keys(AGENTS).join(', '));
943
+ process.exit(1);
944
+ }
945
+ const { agent, destDir, effectiveGlobal } = resolveAgentDestination({
946
+ agentId,
947
+ isGlobal: !!args.flags.global,
948
+ isProject: !!args.flags.project,
949
+ });
950
+ const dryRun = !!args.flags['dry-run'];
951
+
952
+ log('');
953
+ log(' ' + cyan(`BA Toolkit — Uninstall from ${agent.name}`));
954
+ log(' ' + cyan('================================'));
955
+ log('');
956
+ log(` destination: ${destDir}`);
957
+ log(` scope: ${effectiveGlobal ? 'global (user-wide)' : 'project-level'}`);
958
+ if (dryRun) log(' ' + yellow('mode: dry-run (no files will be removed)'));
959
+ log('');
960
+
961
+ // Safety: this is the only place in the CLI that calls fs.rmSync with
962
+ // recursive: true. Refuse to proceed unless the destination is clearly
963
+ // a ba-toolkit folder (the install paths in AGENTS all end in
964
+ // `ba-toolkit/`). Without this check, a corrupted AGENTS entry or a
965
+ // future bug could turn this into `rm -rf $HOME`.
966
+ if (path.basename(destDir) !== 'ba-toolkit') {
967
+ logError(`Refusing to remove suspicious destination (not a ba-toolkit folder): ${destDir}`);
968
+ process.exit(1);
969
+ }
970
+
971
+ if (!fs.existsSync(destDir)) {
972
+ log(' ' + gray(`Nothing to uninstall — ${destDir} does not exist.`));
973
+ log('');
974
+ return;
975
+ }
976
+
977
+ // Count files for the preview message and final confirmation.
978
+ let fileCount = 0;
979
+ (function walk(d) {
980
+ for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
981
+ const p = path.join(d, entry.name);
982
+ if (entry.isDirectory()) walk(p);
983
+ else fileCount++;
984
+ }
985
+ })(destDir);
986
+
987
+ log(` Found ${bold(fileCount)} files in the destination.`);
988
+
989
+ if (dryRun) {
990
+ log(' ' + yellow(`would remove ${fileCount} files from ${destDir}.`));
991
+ log('');
992
+ return;
993
+ }
994
+
995
+ log('');
996
+ const answer = await prompt(` Remove ${destDir}? (y/N): `);
997
+ if (answer.toLowerCase() !== 'y') {
998
+ log(' Cancelled.');
999
+ log('');
1000
+ return;
1001
+ }
1002
+ fs.rmSync(destDir, { recursive: true, force: true });
1003
+ log(' ' + green(`Removed ${fileCount} files from ${destDir}.`));
1004
+ log(' ' + yellow(agent.restartHint));
1005
+ log('');
1006
+ }
1007
+
587
1008
  function cmdHelp() {
588
1009
  log(`${bold('ba-toolkit')} v${PKG.version} — AI-powered Business Analyst pipeline
589
1010
 
@@ -597,25 +1018,37 @@ ${bold('COMMANDS')}
597
1018
  skills into the chosen agent's directory.
598
1019
  install --for <agent> Install (or re-install) skills into an
599
1020
  agent's directory without creating a project.
600
-
601
- ${bold('INIT OPTIONS')}
602
- --name <name> Skip the project name prompt
603
- --slug <slug> Skip the slug prompt (auto-derived from name)
604
- --domain <id> Skip the domain menu (e.g. saas, fintech)
605
- --for <agent> Skip the agent menu (e.g. claude-code)
606
- --no-install Create the project structure only; don't
607
- install skills. Useful for CI or when you
608
- want to pick the agent later.
609
- --global Install agent skills user-wide
610
- --project Install agent skills project-level (default
611
- when the agent supports it)
612
- --dry-run Preview the install step without writing
613
-
614
- ${bold('INSTALL OPTIONS')}
615
- --for <agent> One of: ${Object.keys(AGENTS).join(', ')}
616
- --global User-wide install
617
- --project Project-level install (default when supported)
618
- --dry-run Preview without writing files
1021
+ uninstall --for <agent> Remove BA Toolkit skills from an agent's
1022
+ directory. Asks for confirmation before
1023
+ deleting; supports --dry-run.
1024
+ upgrade --for <agent> Refresh skills after a toolkit version bump.
1025
+ Compares the installed version sentinel
1026
+ against the package version, wipes the old
1027
+ install on mismatch, and re-runs install.
1028
+ Aliased as 'update'.
1029
+ status Scan all known install locations for every
1030
+ supported agent (project + global) and
1031
+ report which versions are installed where.
1032
+ Read-only; no flags.
1033
+
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
619
1052
 
620
1053
  ${bold('GENERAL OPTIONS')}
621
1054
  --version, -v Print version and exit
@@ -635,6 +1068,18 @@ ${bold('EXAMPLES')}
635
1068
  ba-toolkit install --for claude-code
636
1069
  ba-toolkit install --for cursor --dry-run
637
1070
 
1071
+ # Remove skills from an agent (asks for confirmation).
1072
+ ba-toolkit uninstall --for claude-code
1073
+ ba-toolkit uninstall --for claude-code --global
1074
+ ba-toolkit uninstall --for cursor --dry-run
1075
+
1076
+ # After 'npm update -g @kudusov.takhir/ba-toolkit', refresh the skills.
1077
+ ba-toolkit upgrade --for claude-code
1078
+ ba-toolkit upgrade --for cursor --dry-run
1079
+
1080
+ # See where (and which version) BA Toolkit is installed.
1081
+ ba-toolkit status
1082
+
638
1083
  ${bold('LEARN MORE')}
639
1084
  https://github.com/TakhirKudusov/ba-toolkit
640
1085
  `);
@@ -644,6 +1089,7 @@ ${bold('LEARN MORE')}
644
1089
 
645
1090
  async function main() {
646
1091
  const args = parseArgs(process.argv.slice(2));
1092
+ validateFlags(args);
647
1093
 
648
1094
  if (args.flags.version || args.flags.v) {
649
1095
  log(PKG.version);
@@ -663,6 +1109,16 @@ async function main() {
663
1109
  case 'install':
664
1110
  await cmdInstall(args);
665
1111
  break;
1112
+ case 'uninstall':
1113
+ await cmdUninstall(args);
1114
+ break;
1115
+ case 'upgrade':
1116
+ case 'update':
1117
+ await cmdUpgrade(args);
1118
+ break;
1119
+ case 'status':
1120
+ cmdStatus();
1121
+ break;
666
1122
  case 'help':
667
1123
  cmdHelp();
668
1124
  break;
@@ -673,26 +1129,49 @@ async function main() {
673
1129
  }
674
1130
  }
675
1131
 
676
- // Clean exit on Ctrl+C: print on a fresh line so we don't append to a
677
- // half-typed prompt, close the readline interface so the terminal is
678
- // returned to a sane state, then exit with the conventional 130 code.
679
- process.on('SIGINT', () => {
680
- console.log('\n ' + yellow('Cancelled.'));
681
- closeReadline();
682
- process.exit(130);
683
- });
684
-
685
- main()
686
- .then(() => {
687
- closeReadline();
688
- })
689
- .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.'));
690
1159
  closeReadline();
691
- if (err && err.code === 'INPUT_CLOSED') {
692
- logError('Input stream closed before all prompts could be answered.');
693
- log('Pass remaining values as flags (e.g. --name, --domain, --for) or run interactively.');
694
- process.exit(1);
695
- }
696
- logError(err && (err.stack || err.message) || String(err));
697
- process.exit(1);
1160
+ process.exit(130);
698
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.3.2",
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)