@kudusov.takhir/ba-toolkit 1.3.1 → 1.4.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,39 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
11
11
 
12
12
  ---
13
13
 
14
+ ## [1.4.0] — 2026-04-08
15
+
16
+ ### Added
17
+
18
+ - **`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`.
19
+ - **`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.
20
+ - **`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.
21
+ - **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.
22
+ - **`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.
23
+
24
+ ### Fixed
25
+
26
+ - **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.
27
+
28
+ ### Internal
29
+
30
+ - **`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.
31
+ - **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.
32
+
33
+ ---
34
+
35
+ ## [1.3.2] — 2026-04-08
36
+
37
+ ### Fixed
38
+
39
+ - **`bin/ba-toolkit.js` `cmdInit` ignored `runInstall` return value.** When the user declined the "Overwrite? (y/N)" prompt for an existing skill destination, `runInstall` returned `false`, but `cmdInit` ignored it and printed the success path ("Project is ready. Next steps: Restart Claude Code to load the new skills") even though no skills were installed. The next-steps block now branches on the install result and tells the user how to retry: `ba-toolkit install --for {agentId}`.
40
+ - **`parseArgs` did not accept `--key=value` form.** The hand-rolled parser only understood `--key value` (space-separated). Users typing the GNU long-option style accepted by git/npm/gh (`--name=MyApp`, `--domain=saas`) silently lost the value — the flag was stored as `name=MyApp` set to `true` and the script then prompted for the project name interactively. Both forms now work and can be mixed in a single invocation. Splits on the first `=` only, so values containing further `=` characters are preserved (e.g. `--name="Foo=Bar"`).
41
+ - **No SIGINT handler / readline lifecycle issues.** Each `prompt()` call previously created a fresh `readline.Interface` and closed it after the answer. Hitting Ctrl+C mid-prompt killed Node abruptly and left some terminals in raw mode. Piped stdin that closed mid-flow caused the next prompt's promise to hang forever and the process to exit silently with no error message. Fixed by switching to a single shared `readline.Interface` per CLI invocation, adding a `process.on('SIGINT')` handler that prints a clean `Cancelled.` message and exits with code 130, and rejecting in-flight prompt promises with `err.code = 'INPUT_CLOSED'` when the input stream closes prematurely. The outer `main().catch(...)` filters this code to print a friendly two-line message instead of a Node stack trace.
42
+ - **Silent failure when slug could not be derived from a non-ASCII name.** `sanitiseSlug` strips everything outside `[a-z0-9-]`, so `--name "Проект Космос"` or `--name "🚀"` produced an empty derived slug. In the non-interactive path the script then exited with a generic `Invalid or empty slug` error with no clue about why. Now in the non-interactive path it prints `Cannot derive a slug from "{name}" — it contains no ASCII letters or digits` plus a one-line workaround (`Pass an explicit slug with --slug, e.g. --slug my-project`). In the interactive path it prints a gray hint above the manual slug prompt explaining we couldn't auto-derive.
43
+ - **`AGENTS.md` template was missing 8 of the 21 skills.** `renderAgentsMd` emitted a "Pipeline Status" table with 13 rows — the 12 numbered stages + the `7a` sub-step. The 8 cross-cutting utilities added in v1.1 and v1.2 (`/trace`, `/clarify`, `/analyze`, `/estimate`, `/glossary`, `/export`, `/risk`, `/sprint`) were missing entirely. Since `AGENTS.md` is the project context every AI agent reads on entry to a new session, those 8 skills were effectively invisible — agents didn't know they existed without re-reading README. Added a second "Cross-cutting Tools" section below the pipeline table listing all 8, with descriptions copied from the canonical README pipeline table. A `MAINTENANCE` comment above the function reminds future-me to update both tables when adding a new skill.
44
+
45
+ ---
46
+
14
47
  ## [1.3.1] — 2026-04-08
15
48
 
16
49
  ### Fixed
@@ -234,7 +267,9 @@ CI scripts that relied on the old behaviour (`init` creates files only, `install
234
267
 
235
268
  ---
236
269
 
237
- [Unreleased]: https://github.com/TakhirKudusov/ba-toolkit/compare/v1.3.1...HEAD
270
+ [Unreleased]: https://github.com/TakhirKudusov/ba-toolkit/compare/v1.4.0...HEAD
271
+ [1.4.0]: https://github.com/TakhirKudusov/ba-toolkit/compare/v1.3.2...v1.4.0
272
+ [1.3.2]: https://github.com/TakhirKudusov/ba-toolkit/compare/v1.3.1...v1.3.2
238
273
  [1.3.1]: https://github.com/TakhirKudusov/ba-toolkit/compare/v1.3.0...v1.3.1
239
274
  [1.3.0]: https://github.com/TakhirKudusov/ba-toolkit/compare/v1.2.5...v1.3.0
240
275
  [1.2.5]: https://github.com/TakhirKudusov/ba-toolkit/compare/v1.2.4...v1.2.5
package/bin/ba-toolkit.js CHANGED
@@ -94,6 +94,15 @@ function parseArgs(argv) {
94
94
  break;
95
95
  }
96
96
  if (a.startsWith('--')) {
97
+ // Support --key=value form (in addition to --key value). The `=` must
98
+ // come after at least one character of key, so `--=value` and `--`
99
+ // alone fall through. Splits on the FIRST `=` only — values may
100
+ // contain further `=` characters.
101
+ const eqIdx = a.indexOf('=');
102
+ if (eqIdx > 2) {
103
+ args.flags[a.slice(2, eqIdx)] = a.slice(eqIdx + 1);
104
+ continue;
105
+ }
97
106
  const key = a.slice(2);
98
107
  const next = argv[i + 1];
99
108
  if (next !== undefined && !next.startsWith('-')) {
@@ -114,16 +123,42 @@ function parseArgs(argv) {
114
123
 
115
124
  // --- Prompt helper -----------------------------------------------------
116
125
 
126
+ // Shared across all prompts in a single CLI invocation. Creating a new
127
+ // readline.Interface for every question (the previous approach) made Ctrl+C
128
+ // handling unreliable, leaked listeners on stdin, and broke when stdin was
129
+ // piped (EOF on the second create). One interface per process, closed by
130
+ // closeReadline() once main() finishes (or by the SIGINT handler).
131
+ let sharedRl = null;
132
+
117
133
  function prompt(question) {
118
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
119
- return new Promise((resolve) => {
120
- rl.question(question, (answer) => {
121
- rl.close();
134
+ if (!sharedRl) {
135
+ sharedRl = readline.createInterface({ input: process.stdin, output: process.stdout });
136
+ }
137
+ return new Promise((resolve, reject) => {
138
+ let answered = false;
139
+ const onClose = () => {
140
+ if (!answered) {
141
+ const err = new Error('input stream closed before answer');
142
+ err.code = 'INPUT_CLOSED';
143
+ reject(err);
144
+ }
145
+ };
146
+ sharedRl.once('close', onClose);
147
+ sharedRl.question(question, (answer) => {
148
+ answered = true;
149
+ sharedRl.removeListener('close', onClose);
122
150
  resolve(answer.trim());
123
151
  });
124
152
  });
125
153
  }
126
154
 
155
+ function closeReadline() {
156
+ if (sharedRl) {
157
+ sharedRl.close();
158
+ sharedRl = null;
159
+ }
160
+ }
161
+
127
162
  // --- Utilities ---------------------------------------------------------
128
163
 
129
164
  function sanitiseSlug(input) {
@@ -222,6 +257,12 @@ function skillToMdc(srcPath, destPath) {
222
257
  return { destPath: newDestPath, content: mdcFrontmatter + body };
223
258
  }
224
259
 
260
+ // MAINTENANCE: when adding a new skill, update BOTH tables below.
261
+ // - Sequential pipeline stages (numbered 0-11 + 7a sub-step) go in
262
+ // "Pipeline Status" — they have a per-project status that progresses.
263
+ // - Cross-cutting utilities (no fixed stage) go in "Cross-cutting Tools".
264
+ // 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.
225
266
  function renderAgentsMd({ name, slug, domain }) {
226
267
  return `# BA Toolkit — Project Context
227
268
 
@@ -253,6 +294,21 @@ function renderAgentsMd({ name, slug, domain }) {
253
294
  | 10 | /scenarios | ⬜ Not started | — |
254
295
  | 11 | /handoff | ⬜ Not started | — |
255
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
+
256
312
  ## Key Constraints
257
313
 
258
314
  - Domain: ${domain}
@@ -293,12 +349,22 @@ async function cmdInit(args) {
293
349
  if (!slug) {
294
350
  const derived = sanitiseSlug(name);
295
351
  if (nameFromFlag) {
296
- // Non-interactive path: silently accept the derived slug.
352
+ // Non-interactive path. Either accept the derived slug, or fail
353
+ // loudly with a hint when the name has no ASCII letters/digits to
354
+ // derive from (e.g. `--name "Проект"` or `--name "🚀"`). Without
355
+ // this branch the user got an opaque "Invalid or empty slug" with
356
+ // no clue why.
357
+ if (!derived) {
358
+ logError(`Cannot derive a slug from "${name}" — it contains no ASCII letters or digits.`);
359
+ log('Pass an explicit slug with --slug, e.g. --slug my-project');
360
+ process.exit(1);
361
+ }
297
362
  slug = derived;
298
363
  } else if (derived) {
299
364
  const custom = await prompt(` Project slug [${cyan(derived)}]: `);
300
365
  slug = custom || derived;
301
366
  } else {
367
+ log(' ' + gray(`(could not derive a slug from "${name}" — please type one manually)`));
302
368
  slug = await prompt(' Project slug (lowercase, hyphens only): ');
303
369
  }
304
370
  }
@@ -389,9 +455,13 @@ async function cmdInit(args) {
389
455
  }
390
456
 
391
457
  // --- 6. Install skills for the selected agent ---
458
+ // installed: null = no install attempted (--no-install or no agentId),
459
+ // true = install succeeded,
460
+ // false = install was cancelled (e.g. user declined overwrite).
461
+ let installed = null;
392
462
  if (!skipInstall && agentId) {
393
463
  log('');
394
- await runInstall({
464
+ installed = await runInstall({
395
465
  agentId,
396
466
  isGlobal: !!args.flags.global,
397
467
  isProject: !!args.flags.project,
@@ -405,10 +475,16 @@ async function cmdInit(args) {
405
475
  log(' ' + cyan(`Project '${name}' (${slug}) is ready.`));
406
476
  log('');
407
477
  log(' ' + yellow('Next steps:'));
408
- if (!skipInstall && agentId) {
478
+ if (installed === true) {
409
479
  log(' 1. ' + AGENTS[agentId].restartHint);
410
480
  log(' 2. Optional: run /principles to define project-wide conventions');
411
481
  log(' 3. Run /brief to start the BA pipeline');
482
+ } else if (installed === false) {
483
+ log(' 1. Skill install was cancelled. To install later, run:');
484
+ log(' ' + gray(`ba-toolkit install --for ${agentId}`));
485
+ log(' 2. Open your AI assistant (Claude, Cursor, etc.)');
486
+ log(' 3. Optional: run /principles to define project-wide conventions');
487
+ log(' 4. Run /brief to start the BA pipeline');
412
488
  } else {
413
489
  log(' 1. Install skills for your agent:');
414
490
  log(' ' + gray('ba-toolkit install --for claude-code'));
@@ -421,10 +497,39 @@ async function cmdInit(args) {
421
497
  log('');
422
498
  }
423
499
 
424
- // Core install logic. Shared between `cmdInstall` (standalone) and `cmdInit`
425
- // (full setup). Returns true on success, false if the user declined to
426
- // overwrite an existing destination.
427
- async function runInstall({ agentId, isGlobal, isProject, dryRun, showHeader = true }) {
500
+ // Marker file written into the install destination after a successful copy.
501
+ // Lets `upgrade` and `status` (future command) tell which package version
502
+ // is currently installed without diffing every file. Hidden file with no
503
+ // `.md` / `.mdc` extension so the agent's skill loader ignores it.
504
+ const SENTINEL_FILENAME = '.ba-toolkit-version';
505
+
506
+ function readSentinel(destDir) {
507
+ const p = path.join(destDir, SENTINEL_FILENAME);
508
+ if (!fs.existsSync(p)) return null;
509
+ try {
510
+ return JSON.parse(fs.readFileSync(p, 'utf8'));
511
+ } catch {
512
+ return null;
513
+ }
514
+ }
515
+
516
+ function writeSentinel(destDir) {
517
+ const payload = {
518
+ version: PKG.version,
519
+ installedAt: new Date().toISOString(),
520
+ };
521
+ fs.writeFileSync(
522
+ path.join(destDir, SENTINEL_FILENAME),
523
+ JSON.stringify(payload, null, 2) + '\n',
524
+ );
525
+ }
526
+
527
+ // Core install logic. Shared between `cmdInstall` (standalone), `cmdInit`
528
+ // (full setup), and `cmdUpgrade`. Returns true on success, false if the
529
+ // user declined to overwrite an existing destination. Pass `force: true`
530
+ // to skip the overwrite prompt — `cmdUpgrade` uses this because it has
531
+ // already wiped the destination and explicitly knows the overwrite is ok.
532
+ async function runInstall({ agentId, isGlobal, isProject, dryRun, showHeader = true, force = false }) {
428
533
  const agent = AGENTS[agentId];
429
534
  if (!agent) {
430
535
  logError(`Unknown agent: ${agentId}`);
@@ -462,7 +567,7 @@ async function runInstall({ agentId, isGlobal, isProject, dryRun, showHeader = t
462
567
  log(` format: ${agent.format === 'mdc' ? '.mdc (converted from SKILL.md)' : 'SKILL.md (native)'}`);
463
568
  if (dryRun) log(' ' + yellow('mode: dry-run (no files will be written)'));
464
569
 
465
- if (fs.existsSync(destDir) && !dryRun) {
570
+ if (fs.existsSync(destDir) && !dryRun && !force) {
466
571
  const answer = await prompt(` ${destDir} already exists. Overwrite? (y/N): `);
467
572
  if (answer.toLowerCase() !== 'y') {
468
573
  log(' cancelled.');
@@ -479,6 +584,10 @@ async function runInstall({ agentId, isGlobal, isProject, dryRun, showHeader = t
479
584
  process.exit(1);
480
585
  }
481
586
 
587
+ if (!dryRun) {
588
+ writeSentinel(destDir);
589
+ }
590
+
482
591
  log(' ' + green(`${dryRun ? 'would copy' : 'copied'} ${copied.length} files.`));
483
592
  if (!dryRun && agent.format === 'mdc') {
484
593
  log(' ' + gray('SKILL.md files converted to .mdc rule format.'));
@@ -508,6 +617,271 @@ async function cmdInstall(args) {
508
617
  log('');
509
618
  }
510
619
 
620
+ // Resolve agent + scope (project vs global) into the target directory
621
+ // path. Shared validation for cmdUninstall — `runInstall` does its own
622
+ // version of this inline; both could be unified later.
623
+ function resolveAgentDestination({ agentId, isGlobal, isProject }) {
624
+ const agent = AGENTS[agentId];
625
+ if (!agent) {
626
+ logError(`Unknown agent: ${agentId}`);
627
+ log('Supported: ' + Object.keys(AGENTS).join(', '));
628
+ process.exit(1);
629
+ }
630
+ let effectiveGlobal = !!isGlobal;
631
+ if (!isGlobal && !isProject) {
632
+ effectiveGlobal = !agent.projectPath;
633
+ }
634
+ if (effectiveGlobal && !agent.globalPath) {
635
+ logError(`${agent.name} does not support --global install.`);
636
+ process.exit(1);
637
+ }
638
+ if (!effectiveGlobal && !agent.projectPath) {
639
+ logError(`${agent.name} does not support project-level install. Use --global.`);
640
+ process.exit(1);
641
+ }
642
+ const destDir = effectiveGlobal ? agent.globalPath : path.resolve(process.cwd(), agent.projectPath);
643
+ return { agent, destDir, effectiveGlobal };
644
+ }
645
+
646
+ function cmdStatus() {
647
+ log('');
648
+ log(' ' + cyan('BA Toolkit — Installation Status'));
649
+ log(' ' + cyan('================================'));
650
+ log('');
651
+ log(` package version: ${PKG.version}`);
652
+ log(` scanning from: ${process.cwd()}`);
653
+ log('');
654
+
655
+ // Walk every (agent × scope) combination and collect the ones whose
656
+ // destination directory actually exists. Project-scope paths resolve
657
+ // against the current working directory; global paths are absolute.
658
+ const rows = [];
659
+ for (const [agentId, agent] of Object.entries(AGENTS)) {
660
+ if (agent.projectPath) {
661
+ const projectDir = path.resolve(process.cwd(), agent.projectPath);
662
+ if (fs.existsSync(projectDir)) {
663
+ const sentinel = readSentinel(projectDir);
664
+ rows.push({
665
+ agentName: agent.name,
666
+ agentId,
667
+ scope: 'project',
668
+ path: projectDir,
669
+ version: sentinel ? sentinel.version : null,
670
+ installedAt: sentinel ? sentinel.installedAt : null,
671
+ });
672
+ }
673
+ }
674
+ if (agent.globalPath) {
675
+ if (fs.existsSync(agent.globalPath)) {
676
+ const sentinel = readSentinel(agent.globalPath);
677
+ rows.push({
678
+ agentName: agent.name,
679
+ agentId,
680
+ scope: 'global',
681
+ path: agent.globalPath,
682
+ version: sentinel ? sentinel.version : null,
683
+ installedAt: sentinel ? sentinel.installedAt : null,
684
+ });
685
+ }
686
+ }
687
+ }
688
+
689
+ if (rows.length === 0) {
690
+ log(' ' + gray('No BA Toolkit installations found in any known location.'));
691
+ log(' ' + gray("Run 'ba-toolkit install --for <agent>' to install one."));
692
+ log('');
693
+ return;
694
+ }
695
+
696
+ log(` Found ${bold(rows.length)} installation${rows.length === 1 ? '' : 's'}:`);
697
+ log('');
698
+
699
+ for (const row of rows) {
700
+ let versionLabel;
701
+ if (!row.version) {
702
+ versionLabel = gray('(unknown — pre-1.4 install with no sentinel)');
703
+ } else if (row.version === PKG.version) {
704
+ versionLabel = green(row.version + ' (current)');
705
+ } else {
706
+ versionLabel = yellow(row.version + ' (outdated)');
707
+ }
708
+ log(` ${bold(row.agentName)} ${gray('(' + row.agentId + ', ' + row.scope + ')')}`);
709
+ log(` path: ${row.path}`);
710
+ log(` version: ${versionLabel}`);
711
+ if (row.installedAt) {
712
+ log(` installed: ${gray(row.installedAt)}`);
713
+ }
714
+ log('');
715
+ }
716
+
717
+ const stale = rows.filter((r) => !r.version || r.version !== PKG.version);
718
+ if (stale.length > 0) {
719
+ log(' ' + yellow(`${stale.length} installation${stale.length === 1 ? '' : 's'} not at version ${PKG.version}.`));
720
+ log(' ' + gray("Run 'ba-toolkit upgrade --for <agent>' to refresh."));
721
+ log('');
722
+ } else {
723
+ log(' ' + green('All installations are up to date.'));
724
+ log('');
725
+ }
726
+ }
727
+
728
+ async function cmdUpgrade(args) {
729
+ const agentId = args.flags.for;
730
+ if (!agentId || agentId === true) {
731
+ logError('--for <agent> is required.');
732
+ log('Supported agents: ' + Object.keys(AGENTS).join(', '));
733
+ process.exit(1);
734
+ }
735
+ const { agent, destDir, effectiveGlobal } = resolveAgentDestination({
736
+ agentId,
737
+ isGlobal: !!args.flags.global,
738
+ isProject: !!args.flags.project,
739
+ });
740
+ const dryRun = !!args.flags['dry-run'];
741
+
742
+ log('');
743
+ log(' ' + cyan(`BA Toolkit — Upgrade for ${agent.name}`));
744
+ log(' ' + cyan('================================'));
745
+ log('');
746
+ log(` destination: ${destDir}`);
747
+ log(` scope: ${effectiveGlobal ? 'global (user-wide)' : 'project-level'}`);
748
+
749
+ if (!fs.existsSync(destDir)) {
750
+ log('');
751
+ log(' ' + gray(`No installation found at ${destDir}.`));
752
+ log(' ' + gray(`Run \`ba-toolkit install --for ${agentId}\` first.`));
753
+ log('');
754
+ return;
755
+ }
756
+
757
+ const sentinel = readSentinel(destDir);
758
+ const currentVersion = PKG.version;
759
+ const installedVersion = sentinel ? sentinel.version : null;
760
+
761
+ if (installedVersion === currentVersion) {
762
+ log(` installed: ${installedVersion} (current)`);
763
+ log(` package: ${currentVersion}`);
764
+ log('');
765
+ log(' ' + green('Already up to date.'));
766
+ log(' ' + gray(`To force a clean reinstall, run \`ba-toolkit install --for ${agentId}\`.`));
767
+ log('');
768
+ return;
769
+ }
770
+
771
+ log(` installed: ${installedVersion || gray('(unknown — pre-1.4 install with no sentinel)')}`);
772
+ log(` package: ${currentVersion}`);
773
+ if (dryRun) log(' ' + yellow('mode: dry-run (no files will be written)'));
774
+ log('');
775
+
776
+ // Safety: same guard as cmdUninstall — never rmSync anything that
777
+ // doesn't look like a ba-toolkit folder.
778
+ if (path.basename(destDir) !== 'ba-toolkit') {
779
+ logError(`Refusing to upgrade suspicious destination (not a ba-toolkit folder): ${destDir}`);
780
+ process.exit(1);
781
+ }
782
+
783
+ // Count files in the existing install for the dry-run preview.
784
+ if (dryRun) {
785
+ let existingCount = 0;
786
+ (function walk(d) {
787
+ for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
788
+ const p = path.join(d, entry.name);
789
+ if (entry.isDirectory()) walk(p);
790
+ else existingCount++;
791
+ }
792
+ })(destDir);
793
+ log(' ' + yellow(`would remove ${existingCount} existing files`));
794
+ } else {
795
+ log(' ' + green('Removing previous install...'));
796
+ fs.rmSync(destDir, { recursive: true, force: true });
797
+ }
798
+
799
+ const ok = await runInstall({
800
+ agentId,
801
+ isGlobal: effectiveGlobal,
802
+ isProject: !effectiveGlobal,
803
+ dryRun,
804
+ showHeader: false,
805
+ force: true,
806
+ });
807
+ log('');
808
+ if (ok && !dryRun) {
809
+ log(' ' + cyan(`Upgraded to ${currentVersion}.`));
810
+ log(' ' + yellow(agent.restartHint));
811
+ }
812
+ log('');
813
+ }
814
+
815
+ async function cmdUninstall(args) {
816
+ const agentId = args.flags.for;
817
+ if (!agentId || agentId === true) {
818
+ logError('--for <agent> is required.');
819
+ log('Supported agents: ' + Object.keys(AGENTS).join(', '));
820
+ process.exit(1);
821
+ }
822
+ const { agent, destDir, effectiveGlobal } = resolveAgentDestination({
823
+ agentId,
824
+ isGlobal: !!args.flags.global,
825
+ isProject: !!args.flags.project,
826
+ });
827
+ const dryRun = !!args.flags['dry-run'];
828
+
829
+ log('');
830
+ log(' ' + cyan(`BA Toolkit — Uninstall from ${agent.name}`));
831
+ log(' ' + cyan('================================'));
832
+ log('');
833
+ log(` destination: ${destDir}`);
834
+ log(` scope: ${effectiveGlobal ? 'global (user-wide)' : 'project-level'}`);
835
+ if (dryRun) log(' ' + yellow('mode: dry-run (no files will be removed)'));
836
+ log('');
837
+
838
+ // Safety: this is the only place in the CLI that calls fs.rmSync with
839
+ // recursive: true. Refuse to proceed unless the destination is clearly
840
+ // a ba-toolkit folder (the install paths in AGENTS all end in
841
+ // `ba-toolkit/`). Without this check, a corrupted AGENTS entry or a
842
+ // future bug could turn this into `rm -rf $HOME`.
843
+ if (path.basename(destDir) !== 'ba-toolkit') {
844
+ logError(`Refusing to remove suspicious destination (not a ba-toolkit folder): ${destDir}`);
845
+ process.exit(1);
846
+ }
847
+
848
+ if (!fs.existsSync(destDir)) {
849
+ log(' ' + gray(`Nothing to uninstall — ${destDir} does not exist.`));
850
+ log('');
851
+ return;
852
+ }
853
+
854
+ // Count files for the preview message and final confirmation.
855
+ let fileCount = 0;
856
+ (function walk(d) {
857
+ for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
858
+ const p = path.join(d, entry.name);
859
+ if (entry.isDirectory()) walk(p);
860
+ else fileCount++;
861
+ }
862
+ })(destDir);
863
+
864
+ log(` Found ${bold(fileCount)} files in the destination.`);
865
+
866
+ if (dryRun) {
867
+ log(' ' + yellow(`would remove ${fileCount} files from ${destDir}.`));
868
+ log('');
869
+ return;
870
+ }
871
+
872
+ log('');
873
+ const answer = await prompt(` Remove ${destDir}? (y/N): `);
874
+ if (answer.toLowerCase() !== 'y') {
875
+ log(' Cancelled.');
876
+ log('');
877
+ return;
878
+ }
879
+ fs.rmSync(destDir, { recursive: true, force: true });
880
+ log(' ' + green(`Removed ${fileCount} files from ${destDir}.`));
881
+ log(' ' + yellow(agent.restartHint));
882
+ log('');
883
+ }
884
+
511
885
  function cmdHelp() {
512
886
  log(`${bold('ba-toolkit')} v${PKG.version} — AI-powered Business Analyst pipeline
513
887
 
@@ -521,6 +895,18 @@ ${bold('COMMANDS')}
521
895
  skills into the chosen agent's directory.
522
896
  install --for <agent> Install (or re-install) skills into an
523
897
  agent's directory without creating a project.
898
+ uninstall --for <agent> Remove BA Toolkit skills from an agent's
899
+ directory. Asks for confirmation before
900
+ deleting; supports --dry-run.
901
+ upgrade --for <agent> Refresh skills after a toolkit version bump.
902
+ Compares the installed version sentinel
903
+ against the package version, wipes the old
904
+ install on mismatch, and re-runs install.
905
+ Aliased as 'update'.
906
+ status Scan all known install locations for every
907
+ supported agent (project + global) and
908
+ report which versions are installed where.
909
+ Read-only; no flags.
524
910
 
525
911
  ${bold('INIT OPTIONS')}
526
912
  --name <name> Skip the project name prompt
@@ -541,6 +927,20 @@ ${bold('INSTALL OPTIONS')}
541
927
  --project Project-level install (default when supported)
542
928
  --dry-run Preview without writing files
543
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
943
+
544
944
  ${bold('GENERAL OPTIONS')}
545
945
  --version, -v Print version and exit
546
946
  --help, -h Print this help and exit
@@ -559,6 +959,18 @@ ${bold('EXAMPLES')}
559
959
  ba-toolkit install --for claude-code
560
960
  ba-toolkit install --for cursor --dry-run
561
961
 
962
+ # Remove skills from an agent (asks for confirmation).
963
+ ba-toolkit uninstall --for claude-code
964
+ ba-toolkit uninstall --for claude-code --global
965
+ ba-toolkit uninstall --for cursor --dry-run
966
+
967
+ # After 'npm update -g @kudusov.takhir/ba-toolkit', refresh the skills.
968
+ ba-toolkit upgrade --for claude-code
969
+ ba-toolkit upgrade --for cursor --dry-run
970
+
971
+ # See where (and which version) BA Toolkit is installed.
972
+ ba-toolkit status
973
+
562
974
  ${bold('LEARN MORE')}
563
975
  https://github.com/TakhirKudusov/ba-toolkit
564
976
  `);
@@ -587,6 +999,16 @@ async function main() {
587
999
  case 'install':
588
1000
  await cmdInstall(args);
589
1001
  break;
1002
+ case 'uninstall':
1003
+ await cmdUninstall(args);
1004
+ break;
1005
+ case 'upgrade':
1006
+ case 'update':
1007
+ await cmdUpgrade(args);
1008
+ break;
1009
+ case 'status':
1010
+ cmdStatus();
1011
+ break;
590
1012
  case 'help':
591
1013
  cmdHelp();
592
1014
  break;
@@ -597,7 +1019,26 @@ async function main() {
597
1019
  }
598
1020
  }
599
1021
 
600
- main().catch((err) => {
601
- logError(err && (err.stack || err.message) || String(err));
602
- process.exit(1);
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);
603
1029
  });
1030
+
1031
+ main()
1032
+ .then(() => {
1033
+ closeReadline();
1034
+ })
1035
+ .catch((err) => {
1036
+ 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);
1044
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudusov.takhir/ba-toolkit",
3
- "version": "1.3.1",
3
+ "version": "1.4.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",