@voiden/runner 2.1.1 → 2.2.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/bundled-runners/versions.json +6 -6
  3. package/bundled-runners/voiden-faker-runner.js +6 -13
  4. package/dist/discovery.d.ts +11 -0
  5. package/dist/discovery.d.ts.map +1 -0
  6. package/dist/discovery.js +54 -0
  7. package/dist/discovery.js.map +1 -0
  8. package/dist/index.js +263 -111
  9. package/dist/index.js.map +1 -1
  10. package/dist/lib.d.ts +19 -0
  11. package/dist/lib.d.ts.map +1 -0
  12. package/dist/lib.js +19 -0
  13. package/dist/lib.js.map +1 -0
  14. package/dist/mcpInstall.d.ts +48 -0
  15. package/dist/mcpInstall.d.ts.map +1 -0
  16. package/dist/mcpInstall.js +194 -0
  17. package/dist/mcpInstall.js.map +1 -0
  18. package/dist/plugins/registry.d.ts +8 -1
  19. package/dist/plugins/registry.d.ts.map +1 -1
  20. package/dist/plugins/registry.js +13 -2
  21. package/dist/plugins/registry.js.map +1 -1
  22. package/dist/plugins/store.d.ts +28 -1
  23. package/dist/plugins/store.d.ts.map +1 -1
  24. package/dist/plugins/store.js +46 -9
  25. package/dist/plugins/store.js.map +1 -1
  26. package/dist/plugins/versionInfo.d.ts +15 -0
  27. package/dist/plugins/versionInfo.d.ts.map +1 -0
  28. package/dist/plugins/versionInfo.js +18 -0
  29. package/dist/plugins/versionInfo.js.map +1 -0
  30. package/dist/resultBlock.d.ts +31 -0
  31. package/dist/resultBlock.d.ts.map +1 -0
  32. package/dist/resultBlock.js +121 -0
  33. package/dist/resultBlock.js.map +1 -0
  34. package/dist/runner.d.ts +11 -0
  35. package/dist/runner.d.ts.map +1 -1
  36. package/dist/runner.js +61 -2
  37. package/dist/runner.js.map +1 -1
  38. package/dist/runtimeVars.js +3 -3
  39. package/dist/runtimeVars.js.map +1 -1
  40. package/dist/skillContent.d.ts +8 -0
  41. package/dist/skillContent.d.ts.map +1 -0
  42. package/dist/skillContent.js +42 -0
  43. package/dist/skillContent.js.map +1 -0
  44. package/package.json +10 -1
package/dist/index.js CHANGED
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { program } from 'commander';
3
- import { readFileSync, existsSync, statSync, writeFileSync, mkdirSync, unlinkSync } from 'fs';
4
- import { resolve, basename, join, dirname } from 'path';
3
+ import { readFileSync, existsSync, writeFileSync, mkdirSync, unlinkSync } from 'fs';
4
+ import { resolve, relative, basename, join, dirname } from 'path';
5
5
  import { fileURLToPath } from 'url';
6
- import { readdir } from 'fs/promises';
7
6
  import chalk from 'chalk';
8
7
  import { runVoidFile } from './runner.js';
8
+ import { resolveFiles } from './discovery.js';
9
9
  import { loadEnabledPlugins } from './plugins/loader.js';
10
10
  import { exportToCsv } from './report/csv.js';
11
11
  import { sendMailReport } from './report/mail.js';
@@ -14,8 +14,31 @@ import { downloadCoreRunner } from './plugins/loader.js';
14
14
  import { fetchCommunityPlugins, findCommunityPlugin, hasCommunityRunner, installCommunityRunner, } from './plugins/community.js';
15
15
  import { installPlugin, uninstallPlugin, setPluginEnabled, setPluginVersion, getAllInstalledPlugins, readStore, STORE_DIR, } from './plugins/store.js';
16
16
  import { checkForPluginUpdates } from './plugins/updateCheck.js';
17
+ import { getInstalledPluginInfo } from './plugins/versionInfo.js';
18
+ import { parseVoidFile } from './parser.js';
19
+ import { classifyBlockVersion } from '@voiden/executors';
20
+ import { installMcpIntegration, uninstallMcpIntegration, getMcpStatus } from './mcpInstall.js';
21
+ import { RUNNER_SKILL_MARKDOWN } from './skillContent.js';
17
22
  import { appendSessionResults, loadSessionResults, clearSession, } from './session.js';
18
23
  // ─────────────────────────────────────────────────────────────────────────────
24
+ // Exit codes — a stable, documented contract CI pipelines can branch on.
25
+ //
26
+ // 0 success — all requests passed
27
+ // 1 one or more requests failed (assertions/errors), or --bail /
28
+ // --fail-on-error triggered — unchanged from prior releases
29
+ // 2 the runner could not execute the run at all: bad CLI args/flags,
30
+ // missing files, missing plugins, invalid env — a pipeline/config
31
+ // problem, not an API failure
32
+ //
33
+ // See CHANGELOG.md and docs.voiden.md/docs/developer-tools/voiden-runner/ci-cd
34
+ // ─────────────────────────────────────────────────────────────────────────────
35
+ const EXIT_SUCCESS = 0;
36
+ const EXIT_RUN_FAILURE = 1;
37
+ const EXIT_USAGE_ERROR = 2;
38
+ /** JSON output schema version — bump whenever a field is renamed, removed, or
39
+ * reinterpreted (adding a field is not a breaking change and does not need a bump). */
40
+ const JSON_SCHEMA_VERSION = '1';
41
+ // ─────────────────────────────────────────────────────────────────────────────
19
42
  // Helpers
20
43
  // ─────────────────────────────────────────────────────────────────────────────
21
44
  function loadEnvFile(envPath) {
@@ -49,50 +72,6 @@ function formatDuration(ms) {
49
72
  return `${ms}ms`;
50
73
  return `${(ms / 1000).toFixed(2)}s`;
51
74
  }
52
- /** Recursively collect all .void files under a directory. */
53
- async function collectVoidFiles(inputPath) {
54
- const abs = resolve(inputPath);
55
- if (!existsSync(abs))
56
- return [];
57
- const stat = statSync(abs);
58
- if (stat.isFile()) {
59
- return abs.endsWith('.void') ? [abs] : [];
60
- }
61
- if (stat.isDirectory()) {
62
- const entries = await readdir(abs, { withFileTypes: true });
63
- const results = [];
64
- for (const entry of entries) {
65
- const full = resolve(abs, entry.name);
66
- if (entry.isDirectory()) {
67
- results.push(...(await collectVoidFiles(full)));
68
- }
69
- else if (entry.isFile() && entry.name.endsWith('.void')) {
70
- results.push(full);
71
- }
72
- }
73
- return results;
74
- }
75
- return [];
76
- }
77
- /** Expand a list of paths/globs into resolved .void file paths. */
78
- async function resolveFiles(patterns) {
79
- const resolved = [];
80
- for (const pattern of patterns) {
81
- if (pattern.includes('*')) {
82
- const dir = resolve(pattern.replace(/\/?\*.*$/, '') || '.');
83
- const entries = await readdir(dir, { withFileTypes: true });
84
- for (const entry of entries) {
85
- if (entry.isFile() && entry.name.endsWith('.void')) {
86
- resolved.push(resolve(dir, entry.name));
87
- }
88
- }
89
- }
90
- else {
91
- resolved.push(...(await collectVoidFiles(pattern)));
92
- }
93
- }
94
- return resolved;
95
- }
96
75
  // ─────────────────────────────────────────────────────────────────────────────
97
76
  // Spinner
98
77
  // ─────────────────────────────────────────────────────────────────────────────
@@ -266,18 +245,26 @@ function printRunSummary(results, totalMs) {
266
245
  console.log(DIVIDER);
267
246
  console.log();
268
247
  }
269
- function printRunSummaryJson(results, totalMs, activePlugins) {
248
+ /**
249
+ * Builds the `--json` / `--output-json` payload shape shared by `run` and
250
+ * `report generate`. `schemaVersion` is the stable contract external tooling
251
+ * codes against — see the exit-codes comment above for the versioning rule.
252
+ */
253
+ function buildJsonReport(results, extra = {}) {
270
254
  const passed = results.filter(r => r.result.success).length;
271
- const output = {
255
+ return {
256
+ schemaVersion: JSON_SCHEMA_VERSION,
272
257
  summary: {
273
258
  total: results.length,
274
259
  passed,
275
260
  failed: results.length - passed,
276
- totalDurationMs: totalMs,
277
- activePlugins,
261
+ ...extra,
278
262
  },
279
263
  requests: results.map(r => ({ file: r.file, ...r.result })),
280
264
  };
265
+ }
266
+ function printRunSummaryJson(results, totalMs, activePlugins) {
267
+ const output = buildJsonReport(results, { totalDurationMs: totalMs, activePlugins });
281
268
  console.log(JSON.stringify(output, null, 2));
282
269
  }
283
270
  /** Split CHANGELOG.md into per-version entries on "## " headers (newest first, matching file order). */
@@ -346,6 +333,65 @@ async function notifyPluginUpdates() {
346
333
  // Informational only — ignore failures (e.g. offline)
347
334
  }
348
335
  }
336
+ function scanProjectRequirements(files, cwd) {
337
+ // pluginId → version → files that declared it
338
+ const usages = new Map();
339
+ for (const file of files) {
340
+ let content;
341
+ try {
342
+ content = readFileSync(file, 'utf-8');
343
+ }
344
+ catch {
345
+ continue;
346
+ }
347
+ for (const block of parseVoidFile(content)) {
348
+ const pluginId = block.attrs?.pluginId;
349
+ const pluginVersion = block.attrs?.pluginVersion;
350
+ if (!pluginId || !pluginVersion)
351
+ continue;
352
+ if (!usages.has(pluginId))
353
+ usages.set(pluginId, new Map());
354
+ const versions = usages.get(pluginId);
355
+ if (!versions.has(pluginVersion))
356
+ versions.set(pluginVersion, new Set());
357
+ versions.get(pluginVersion).add(relative(cwd, file));
358
+ }
359
+ }
360
+ const issues = [];
361
+ for (const [pluginId, versions] of usages) {
362
+ for (const [version, fileSet] of versions) {
363
+ const installed = getInstalledPluginInfo(pluginId);
364
+ const status = classifyBlockVersion({ pluginId, pluginVersion: version, blockType: '' }, installed);
365
+ if (status === 'ok')
366
+ continue;
367
+ issues.push({ pluginId, requiredVersion: version, installedVersion: installed?.version, status, files: [...fileSet] });
368
+ }
369
+ }
370
+ return issues;
371
+ }
372
+ function printProjectStatus(issues) {
373
+ if (issues.length === 0)
374
+ return;
375
+ console.log();
376
+ console.log(chalk.yellow(` ⚠ ${issues.length} plugin${issues.length !== 1 ? 's' : ''} ${issues.length !== 1 ? "don't" : "doesn't"} match what this project needs`));
377
+ for (const issue of issues) {
378
+ const have = issue.status === 'not-installed' ? 'not installed'
379
+ : issue.status === 'disabled' ? 'installed but disabled'
380
+ : `v${issue.installedVersion} installed`;
381
+ console.log(chalk.gray(` ${chalk.bold(issue.pluginId.padEnd(24))} requires v${issue.requiredVersion} — ${have}`));
382
+ console.log(chalk.gray(` used in: ${issue.files.join(', ')}`));
383
+ }
384
+ console.log(chalk.gray(` Run: voiden-runner plugin install <name>@<version>`));
385
+ }
386
+ /** Best-effort — never throws, never blocks command output on failure. */
387
+ function notifyProjectStatus(files, cwd) {
388
+ try {
389
+ printProjectStatus(scanProjectRequirements(files, cwd));
390
+ }
391
+ catch {
392
+ // Informational only
393
+ }
394
+ }
349
395
  // ─────────────────────────────────────────────────────────────────────────────
350
396
  // CLI
351
397
  // ─────────────────────────────────────────────────────────────────────────────
@@ -368,7 +414,7 @@ program
368
414
  const changelogPath = resolve(join(dirname(fileURLToPath(import.meta.url)), '../CHANGELOG.md'));
369
415
  if (!existsSync(changelogPath)) {
370
416
  console.error(chalk.red(' ✗ No CHANGELOG.md found for this install.'));
371
- process.exit(1);
417
+ process.exit(EXIT_USAGE_ERROR);
372
418
  }
373
419
  const entries = parseChangelog(readFileSync(changelogPath, 'utf-8'));
374
420
  if (entries.length === 0) {
@@ -382,7 +428,7 @@ program
382
428
  if (!match) {
383
429
  console.error(chalk.red(` ✗ No changelog entry found for version "${version}".`));
384
430
  console.log(chalk.gray(` Available: ${entries.map(e => e.version).join(', ')}`));
385
- process.exit(1);
431
+ process.exit(EXIT_USAGE_ERROR);
386
432
  }
387
433
  toShow = [match];
388
434
  }
@@ -442,14 +488,14 @@ program
442
488
  const envPath = resolve(opts.env);
443
489
  if (!existsSync(envPath)) {
444
490
  console.error(chalk.red(`Env file not found: ${envPath}`));
445
- process.exit(1);
491
+ process.exit(EXIT_USAGE_ERROR);
446
492
  }
447
493
  try {
448
494
  Object.assign(env, loadEnvFile(envPath));
449
495
  }
450
496
  catch (err) {
451
497
  console.error(chalk.red(` ✗ ${err.message}`));
452
- process.exit(1);
498
+ process.exit(EXIT_USAGE_ERROR);
453
499
  }
454
500
  }
455
501
  // 2. Individual --env-var overrides
@@ -458,13 +504,13 @@ program
458
504
  const eq = pair.indexOf('=');
459
505
  if (eq === -1) {
460
506
  console.error(chalk.red(` ✗ Invalid --env-var format: "${pair}" (expected key=value)`));
461
- process.exit(1);
507
+ process.exit(EXIT_USAGE_ERROR);
462
508
  }
463
509
  const key = pair.slice(0, eq).trim();
464
510
  const val = pair.slice(eq + 1).trim().replace(/^["']|["']$/g, '');
465
511
  if (!key) {
466
512
  console.error(chalk.red(` ✗ Invalid --env-var format: "${pair}" (key cannot be empty)`));
467
- process.exit(1);
513
+ process.exit(EXIT_USAGE_ERROR);
468
514
  }
469
515
  env[key] = val;
470
516
  }
@@ -472,7 +518,7 @@ program
472
518
  const resolvedFiles = await resolveFiles(paths);
473
519
  if (resolvedFiles.length === 0) {
474
520
  console.error(chalk.red('No .void files found at the given path(s)'));
475
- process.exit(1);
521
+ process.exit(EXIT_USAGE_ERROR);
476
522
  }
477
523
  // --stop-on-failure is a CI-friendly alias for --bail
478
524
  const stopOnFailure = opts.bail || opts.stopOnFailure;
@@ -490,11 +536,11 @@ program
490
536
  if (opts.mail || opts.mailTo) {
491
537
  if (!mailTo) {
492
538
  console.error(chalk.red(' ✗ Mail error: no recipient found. Please provide --mail-to or set VOIDEN_MAIL_TO.'));
493
- process.exit(1);
539
+ process.exit(EXIT_USAGE_ERROR);
494
540
  }
495
541
  if (!smtpHost) {
496
542
  console.error(chalk.red(' ✗ Mail keys are missing. Please provide SMTP configuration (VOIDEN_SMTP_HOST).'));
497
- process.exit(1);
543
+ process.exit(EXIT_USAGE_ERROR);
498
544
  }
499
545
  }
500
546
  const runStart = Date.now();
@@ -600,16 +646,7 @@ program
600
646
  // ── Output JSON to file (before mail so it can be attached) ──────────────
601
647
  let savedJsonPath;
602
648
  if (opts.outputJson) {
603
- const jsonData = {
604
- summary: {
605
- total: allResults.length,
606
- passed: allResults.filter(r => r.result.success).length,
607
- failed: allResults.filter(r => !r.result.success).length,
608
- totalDurationMs: totalMs,
609
- activePlugins,
610
- },
611
- requests: allResults.map(r => ({ file: r.file, ...r.result })),
612
- };
649
+ const jsonData = buildJsonReport(allResults, { totalDurationMs: totalMs, activePlugins });
613
650
  try {
614
651
  mkdirSync(dirname(opts.outputJson), { recursive: true });
615
652
  writeFileSync(opts.outputJson, JSON.stringify(jsonData, null, 2) + '\n', 'utf-8');
@@ -650,10 +687,12 @@ program
650
687
  console.log(chalk.gray(' (use this exit code in your shell script to abort on failure)'));
651
688
  console.log();
652
689
  }
653
- // Surface plugin update notices — skipped in --json mode so output stays machine-readable
654
- if (!opts.json)
690
+ // Surface plugin update / project-requirements notices — skipped in --json mode so output stays machine-readable
691
+ if (!opts.json) {
655
692
  await notifyPluginUpdates();
656
- process.exit(shouldFail ? 1 : 0);
693
+ notifyProjectStatus(resolvedFiles, process.cwd());
694
+ }
695
+ process.exit(shouldFail ? EXIT_RUN_FAILURE : EXIT_SUCCESS);
657
696
  });
658
697
  // ── voiden-runner session ─────────────────────────────────────────────────────
659
698
  const sessionCmd = program
@@ -768,7 +807,7 @@ reportCmd
768
807
  const results = loadSessionResults();
769
808
  if (results.length === 0) {
770
809
  console.error(chalk.red(' ✗ No results found in session. Run some .void files first.'));
771
- process.exit(1);
810
+ process.exit(EXIT_USAGE_ERROR);
772
811
  }
773
812
  // Load optional .env for report SMTP settings
774
813
  const env = { ...process.env };
@@ -787,12 +826,12 @@ reportCmd
787
826
  if (opts.mail || opts.mailTo) {
788
827
  if (!mailTo) {
789
828
  console.error(chalk.red(' ✗ Mail error: no recipient found. Please provide --mail-to or set VOIDEN_MAIL_TO.'));
790
- process.exit(1);
829
+ process.exit(EXIT_USAGE_ERROR);
791
830
  }
792
831
  const smtpHost = opts.smtpHost || env.VOIDEN_SMTP_HOST;
793
832
  if (!smtpHost) {
794
833
  console.error(chalk.red(' ✗ Mail keys are missing. Please provide SMTP configuration (VOIDEN_SMTP_HOST).'));
795
- process.exit(1);
834
+ process.exit(EXIT_USAGE_ERROR);
796
835
  }
797
836
  }
798
837
  if (!opts.csv && !opts.outputJson && !mailTo) {
@@ -811,14 +850,7 @@ reportCmd
811
850
  }
812
851
  let savedJsonPath;
813
852
  if (opts.outputJson) {
814
- const jsonData = {
815
- summary: {
816
- total: results.length,
817
- passed: results.filter(r => r.result.success).length,
818
- failed: results.filter(r => !r.result.success).length,
819
- },
820
- requests: results.map(r => ({ file: r.file, ...r.result })),
821
- };
853
+ const jsonData = buildJsonReport(results);
822
854
  try {
823
855
  mkdirSync(dirname(opts.outputJson), { recursive: true });
824
856
  writeFileSync(opts.outputJson, JSON.stringify(jsonData, null, 2) + '\n', 'utf-8');
@@ -839,7 +871,7 @@ reportCmd
839
871
  if (!smtpHost) {
840
872
  console.error(chalk.red(' ✗ SMTP configuration required for email reports.'));
841
873
  console.log(chalk.gray(' Set VOIDEN_SMTP_HOST in your environment or use --smtp-host.'));
842
- process.exit(1);
874
+ process.exit(EXIT_USAGE_ERROR);
843
875
  }
844
876
  console.log(chalk.gray(` ↑ Sending session report to ${mailTo} …`));
845
877
  try {
@@ -862,6 +894,14 @@ reportCmd
862
894
  }
863
895
  }
864
896
  });
897
+ // Splits a `name` or `name@version` CLI arg. Plugin ids are plain slugs (no
898
+ // leading `@`), so splitting on the first `@` is unambiguous.
899
+ function parsePluginTarget(raw) {
900
+ const at = raw.indexOf('@');
901
+ if (at <= 0)
902
+ return { name: raw };
903
+ return { name: raw.slice(0, at), pinVersion: raw.slice(at + 1) };
904
+ }
865
905
  // ── voiden-runner plugin ──────────────────────────────────────────────────────
866
906
  const pluginCmd = program
867
907
  .command('plugin')
@@ -870,37 +910,44 @@ const pluginCmd = program
870
910
  pluginCmd
871
911
  .command('install [names...]')
872
912
  .description('Install one or more plugins, or all core plugins\n\n' +
873
- ' --all installs all core plugins only. Community plugins must be installed by name.\n\n' +
913
+ ' --all installs all core plugins only. Community plugins must be installed by name.\n' +
914
+ ' Pin an exact version with name@version (e.g. after `voiden-runner lock`, or to\n' +
915
+ ' match a "Block ... requires plugin X vY" error).\n\n' +
874
916
  ' Examples:\n' +
875
917
  ' voiden-runner plugin install --all\n' +
876
918
  ' voiden-runner plugin install voiden-scripting\n' +
877
- ' voiden-runner plugin install apyhub-explorer\n')
919
+ ' voiden-runner plugin install voiden-rest-api@1.4.7\n')
878
920
  .option('--all', 'Install all core plugins (community plugins must be installed by name)')
879
- .action(async (names, opts) => {
921
+ .action(async (rawNames, opts) => {
880
922
  const corePlugins = await getCorePlugins();
881
923
  const communityPlugins = await fetchCommunityPlugins();
882
924
  const targets = opts.all
883
- ? corePlugins.map(p => p.name)
884
- : names;
925
+ ? corePlugins.map(p => ({ name: p.name }))
926
+ : rawNames.map(parsePluginTarget);
885
927
  if (targets.length === 0) {
886
928
  console.error(chalk.red('Specify plugin name(s) or use --all'));
887
929
  console.log(chalk.gray(' Core: ' + corePlugins.map(p => p.name).join(', ')));
888
930
  if (communityPlugins.length > 0) {
889
931
  console.log(chalk.gray(' Community (install by name): ' + communityPlugins.map(p => p.id).join(', ')));
890
932
  }
891
- process.exit(1);
933
+ process.exit(EXIT_USAGE_ERROR);
892
934
  }
893
935
  let installedCount = 0;
894
- for (const name of targets) {
895
- const coreDef = await findPlugin(name);
896
- const commDef = !coreDef ? findCommunityPlugin(name, communityPlugins) : undefined;
897
- if (!coreDef && !commDef) {
936
+ for (const { name, pinVersion } of targets) {
937
+ const foundCoreDef = await findPlugin(name);
938
+ const foundCommDef = !foundCoreDef ? findCommunityPlugin(name, communityPlugins) : undefined;
939
+ if (!foundCoreDef && !foundCommDef) {
898
940
  console.log(chalk.yellow(` ⚠ Unknown plugin "${name}" — skipped`));
899
941
  continue;
900
942
  }
901
- // Core plugins: only download if not already bundled in the package or cached
902
- // from a previous install `bundled: true` plugins should need no network call.
903
- if (coreDef && !hasCoreRunner(name)) {
943
+ // A pinned version overrides the registry's "latest" default this is what
944
+ // makes the fix-it command in version-mismatch errors ("plugin install x@y")
945
+ // actually able to install the exact version a file declares.
946
+ const coreDef = foundCoreDef && pinVersion ? { ...foundCoreDef, version: pinVersion } : foundCoreDef;
947
+ const commDef = foundCommDef && pinVersion ? { ...foundCommDef, version: pinVersion } : foundCommDef;
948
+ // Core plugins: only download if not already bundled/cached — unless a
949
+ // specific version was pinned, in which case always fetch that version.
950
+ if (coreDef && (pinVersion || !hasCoreRunner(name))) {
904
951
  process.stdout.write(` ↓ Downloading runner for ${chalk.bold(name)} …`);
905
952
  try {
906
953
  const ok = await downloadCoreRunner(coreDef.name, coreDef.repo, coreDef.runnerAsset, coreDef.version, false);
@@ -938,6 +985,13 @@ pluginCmd
938
985
  console.log(chalk.green(` ✓ Installed`) + chalk.bold(` ${name}`) + chalk.gray(` — ${description}`));
939
986
  installedCount++;
940
987
  }
988
+ else if (pinVersion) {
989
+ // Re-running install with an explicit pin (e.g. to fix a version-mismatch)
990
+ // should still record the newly-downloaded version even if already "installed".
991
+ setPluginVersion(name, coreDef?.version ?? commDef?.version ?? pinVersion);
992
+ console.log(chalk.green(` ✓ Installed`) + chalk.bold(` ${name}@${pinVersion}`));
993
+ installedCount++;
994
+ }
941
995
  else {
942
996
  console.log(chalk.gray(` · Already installed`) + ` ${name}`);
943
997
  }
@@ -1030,16 +1084,24 @@ pluginCmd
1030
1084
  ' voiden-runner plugin uninstall apyhub-explorer voiden-scripting\n' +
1031
1085
  ' voiden-runner plugin uninstall --all\n')
1032
1086
  .option('--all', 'Uninstall all installed plugins (core and community)')
1033
- .action((names, opts) => {
1034
- const targets = opts.all ? Object.keys(readStore().installedPlugins) : names;
1087
+ .action(async (names, opts) => {
1088
+ // Core plugins are bundled and enabled by default — they may never have
1089
+ // an explicit store record even though they're clearly active, so --all
1090
+ // (and named uninstalls of a bundled plugin) must include the full core
1091
+ // registry, not just names that already happen to have a store record.
1092
+ const corePlugins = await getCorePlugins();
1093
+ const coreNames = new Set(corePlugins.map(p => p.name));
1094
+ const targets = opts.all
1095
+ ? [...new Set([...coreNames, ...getAllInstalledPlugins().map(p => p.name)])]
1096
+ : names;
1035
1097
  if (targets.length === 0) {
1036
1098
  console.error(chalk.red(' Specify plugin name(s) or use --all'));
1037
- process.exit(1);
1099
+ process.exit(EXIT_USAGE_ERROR);
1038
1100
  return;
1039
1101
  }
1040
1102
  let removedCount = 0;
1041
1103
  for (const name of targets) {
1042
- const removed = uninstallPlugin(name);
1104
+ const removed = uninstallPlugin(name, coreNames.has(name));
1043
1105
  if (removed) {
1044
1106
  console.log(chalk.green(` ✓ Uninstalled`) + ` ${name}`);
1045
1107
  removedCount++;
@@ -1064,9 +1126,11 @@ pluginCmd
1064
1126
  .action(async (name, opts) => {
1065
1127
  if (opts.all) {
1066
1128
  const store = readStore();
1067
- // Re-enable all explicitly disabled plugins (core + community)
1129
+ // Re-enable all explicitly disabled plugins (core + community) — but not
1130
+ // uninstalled ones; bringing those back requires an explicit `plugin
1131
+ // install`, not a blanket --all enable.
1068
1132
  const disabled = Object.entries(store.installedPlugins)
1069
- .filter(([, r]) => !r.enabled)
1133
+ .filter(([, r]) => !r.enabled && !r.uninstalled)
1070
1134
  .map(([n]) => n);
1071
1135
  // Also ensure all core plugins that were never in the store are treated as enabled (default)
1072
1136
  const disabledCoreNotInStore = [];
@@ -1083,14 +1147,14 @@ pluginCmd
1083
1147
  }
1084
1148
  if (!name) {
1085
1149
  console.error(chalk.red(' Specify a plugin name or use --all'));
1086
- process.exit(1);
1150
+ process.exit(EXIT_USAGE_ERROR);
1087
1151
  }
1088
1152
  const communityPlugins = await fetchCommunityPlugins();
1089
1153
  const commDef = findCommunityPlugin(name, communityPlugins);
1090
1154
  if (commDef && !hasCommunityRunner(name)) {
1091
1155
  console.log(chalk.red(` ✗ Cannot enable "${name}" — runner not installed`));
1092
1156
  console.log(chalk.gray(` Run: voiden-runner plugin install ${name}`));
1093
- process.exit(1);
1157
+ process.exit(EXIT_USAGE_ERROR);
1094
1158
  }
1095
1159
  setPluginEnabled(name, true);
1096
1160
  console.log(chalk.green(` ✓ Enabled`) + ` ${name}`);
@@ -1125,7 +1189,7 @@ pluginCmd
1125
1189
  }
1126
1190
  if (!name) {
1127
1191
  console.error(chalk.red(' Specify a plugin name or use --all'));
1128
- process.exit(1);
1192
+ process.exit(EXIT_USAGE_ERROR);
1129
1193
  return;
1130
1194
  }
1131
1195
  setPluginEnabled(name, false);
@@ -1155,10 +1219,16 @@ pluginCmd
1155
1219
  console.log(DIVIDER);
1156
1220
  for (const def of corePlugins) {
1157
1221
  const record = store.installedPlugins[def.name];
1158
- const isDisabled = record !== undefined && !record.enabled;
1159
- const statusBadge = isDisabled
1160
- ? chalk.yellow(' · disabled')
1161
- : chalk.green(' ✓ enabled');
1222
+ let statusBadge;
1223
+ if (record?.uninstalled) {
1224
+ statusBadge = chalk.gray(' not installed');
1225
+ }
1226
+ else if (record !== undefined && !record.enabled) {
1227
+ statusBadge = chalk.yellow(' · disabled');
1228
+ }
1229
+ else {
1230
+ statusBadge = chalk.green(' ✓ enabled');
1231
+ }
1162
1232
  console.log(` ${chalk.bold(def.name.padEnd(24))}${statusBadge}${updateBadge(def.name, def.version)}`);
1163
1233
  console.log(chalk.gray(` ${def.description}`));
1164
1234
  }
@@ -1211,5 +1281,87 @@ pluginCmd
1211
1281
  }
1212
1282
  console.log();
1213
1283
  });
1284
+ // ── voiden-runner mcp ─────────────────────────────────────────────────────────
1285
+ //
1286
+ // Enables the AI-agent loop for CLI-only users (no Voiden app installed):
1287
+ // registers @voiden/mcp-server with Claude Code / Codex, and installs a
1288
+ // standalone skill teaching the run/verify/write-back workflow. The Voiden
1289
+ // app's own Settings toggle does the equivalent for desktop users, reusing
1290
+ // the same registration helpers from mcpInstall.ts.
1291
+ function resolveMcpTargets(opts) {
1292
+ // Default to both when neither flag is given — a single command should be
1293
+ // enough to "just enable this".
1294
+ if (!opts.claude && !opts.codex)
1295
+ return { claude: true, codex: true };
1296
+ return { claude: Boolean(opts.claude), codex: Boolean(opts.codex) };
1297
+ }
1298
+ const mcpCmd = program
1299
+ .command('mcp')
1300
+ .description('Enable AI-agent integration — registers @voiden/mcp-server and installs a run/verify skill');
1301
+ mcpCmd
1302
+ .command('install')
1303
+ .description('Register @voiden/mcp-server with Claude Code and/or Codex, and install a skill teaching the run/verify/write-back loop.\n\n' +
1304
+ ' Examples:\n' +
1305
+ ' voiden-runner mcp install # both Claude Code and Codex\n' +
1306
+ ' voiden-runner mcp install --claude # Claude Code only\n' +
1307
+ ' voiden-runner mcp install -p ./my-project # register against a specific project dir (default: cwd)\n' +
1308
+ ' voiden-runner mcp install --local-server ./dist/index.js # before publishing: point at a local build instead of npx\n')
1309
+ .option('--claude', 'Install for Claude Code only')
1310
+ .option('--codex', 'Install for Codex only')
1311
+ .option('-p, --project <path>', 'Project directory to register the MCP server against', '.')
1312
+ .option('--local-server <path>', 'Use `node <path>` instead of `npx -y @voiden/mcp-server` — for testing against a local build before it\'s published')
1313
+ .action((opts) => {
1314
+ const targets = resolveMcpTargets(opts);
1315
+ const serverCommand = opts.localServer
1316
+ ? { command: 'node', args: [resolve(opts.localServer), resolve(opts.project)] }
1317
+ : undefined;
1318
+ const installed = installMcpIntegration(opts.project, targets, RUNNER_SKILL_MARKDOWN, serverCommand);
1319
+ if (installed.length === 0) {
1320
+ console.log(chalk.yellow(' Nothing to install.'));
1321
+ return;
1322
+ }
1323
+ console.log();
1324
+ for (const target of installed) {
1325
+ console.log(chalk.green(` ✓ ${target === 'claude' ? 'Claude Code' : 'Codex'}`) + chalk.gray(` — skill installed, @voiden/mcp-server registered for ${resolve(opts.project)}`));
1326
+ }
1327
+ if (serverCommand) {
1328
+ console.log(chalk.gray(` Using local build: node ${serverCommand.args[0]}`));
1329
+ }
1330
+ console.log();
1331
+ console.log(chalk.gray(' Restart Claude Code / Codex (or run /mcp) to pick up the new server.'));
1332
+ });
1333
+ mcpCmd
1334
+ .command('uninstall')
1335
+ .description('Remove the MCP server registration and skill installed by `mcp install`')
1336
+ .option('--claude', 'Remove Claude Code integration only')
1337
+ .option('--codex', 'Remove Codex integration only')
1338
+ .option('-p, --project <path>', 'Project directory to unregister the MCP server from', '.')
1339
+ .action((opts) => {
1340
+ const targets = resolveMcpTargets(opts);
1341
+ const removed = uninstallMcpIntegration(opts.project, targets);
1342
+ if (removed.length === 0) {
1343
+ console.log(chalk.yellow(' Nothing to remove.'));
1344
+ return;
1345
+ }
1346
+ for (const target of removed) {
1347
+ console.log(chalk.green(` ✓ Removed`) + chalk.gray(` ${target === 'claude' ? 'Claude Code' : 'Codex'} integration`));
1348
+ }
1349
+ });
1350
+ mcpCmd
1351
+ .command('status')
1352
+ .description('Show whether the MCP server + skill are installed for this project')
1353
+ .option('-p, --project <path>', 'Project directory to check', '.')
1354
+ .action((opts) => {
1355
+ const status = getMcpStatus(opts.project);
1356
+ console.log();
1357
+ console.log(chalk.bold(' Claude Code'));
1358
+ console.log(` skill installed: ${status.claude.skillInstalled ? chalk.green('yes') : chalk.gray('no')}`);
1359
+ console.log(` server registered: ${status.claude.serverRegistered ? chalk.green('yes') : chalk.gray('no')}`);
1360
+ console.log();
1361
+ console.log(chalk.bold(' Codex'));
1362
+ console.log(` skill installed: ${status.codex.skillInstalled ? chalk.green('yes') : chalk.gray('no')}`);
1363
+ console.log(` server registered: ${status.codex.serverRegistered ? chalk.green('yes') : chalk.gray('no')}`);
1364
+ console.log();
1365
+ });
1214
1366
  program.parse();
1215
1367
  //# sourceMappingURL=index.js.map