@voiden/runner 2.1.1 → 2.3.0-beta.1

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 (59) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/bundled-runners/versions.json +6 -6
  3. package/bundled-runners/voiden-advanced-auth-runner.js +1 -1
  4. package/bundled-runners/voiden-graphql-runner.js +1 -1
  5. package/bundled-runners/voiden-rest-api-runner.js +1 -1
  6. package/bundled-runners/voiden-sockets-grpcs-runner.js +1 -1
  7. package/dist/discovery.d.ts +11 -0
  8. package/dist/discovery.d.ts.map +1 -0
  9. package/dist/discovery.js +54 -0
  10. package/dist/discovery.js.map +1 -0
  11. package/dist/envFile.d.ts +2 -0
  12. package/dist/envFile.d.ts.map +1 -0
  13. package/dist/envFile.js +59 -0
  14. package/dist/envFile.js.map +1 -0
  15. package/dist/headlessContext.d.ts.map +1 -1
  16. package/dist/headlessContext.js +9 -0
  17. package/dist/headlessContext.js.map +1 -1
  18. package/dist/index.js +261 -130
  19. package/dist/index.js.map +1 -1
  20. package/dist/lib.d.ts +16 -0
  21. package/dist/lib.d.ts.map +1 -0
  22. package/dist/lib.js +18 -0
  23. package/dist/lib.js.map +1 -0
  24. package/dist/plugins/loader.d.ts.map +1 -1
  25. package/dist/plugins/loader.js +2 -0
  26. package/dist/plugins/loader.js.map +1 -1
  27. package/dist/plugins/registry.d.ts +8 -1
  28. package/dist/plugins/registry.d.ts.map +1 -1
  29. package/dist/plugins/registry.js +13 -2
  30. package/dist/plugins/registry.js.map +1 -1
  31. package/dist/plugins/store.d.ts +28 -1
  32. package/dist/plugins/store.d.ts.map +1 -1
  33. package/dist/plugins/store.js +46 -9
  34. package/dist/plugins/store.js.map +1 -1
  35. package/dist/plugins/versionInfo.d.ts +15 -0
  36. package/dist/plugins/versionInfo.d.ts.map +1 -0
  37. package/dist/plugins/versionInfo.js +18 -0
  38. package/dist/plugins/versionInfo.js.map +1 -0
  39. package/dist/requestContainerRegistry.d.ts +36 -0
  40. package/dist/requestContainerRegistry.d.ts.map +1 -0
  41. package/dist/requestContainerRegistry.js +42 -0
  42. package/dist/requestContainerRegistry.js.map +1 -0
  43. package/dist/resultBlock.d.ts +37 -0
  44. package/dist/resultBlock.d.ts.map +1 -0
  45. package/dist/resultBlock.js +143 -0
  46. package/dist/resultBlock.js.map +1 -0
  47. package/dist/runner.d.ts +20 -0
  48. package/dist/runner.d.ts.map +1 -1
  49. package/dist/runner.js +85 -10
  50. package/dist/runner.js.map +1 -1
  51. package/dist/runtimeVars.js +3 -3
  52. package/dist/runtimeVars.js.map +1 -1
  53. package/dist/types.d.ts +1 -5
  54. package/dist/types.d.ts.map +1 -1
  55. package/package.json +11 -2
  56. package/dist/parser.d.ts +0 -24
  57. package/dist/parser.d.ts.map +0 -1
  58. package/dist/parser.js +0 -87
  59. package/dist/parser.js.map +0 -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,29 +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 { classifyBlockVersion, parseVoidFile, installMcpIntegration, uninstallMcpIntegration, getMcpStatus, MCP_SKILL_MARKDOWN, } from '@voiden/executors';
19
+ import { loadEnvFile } from './envFile.js';
17
20
  import { appendSessionResults, loadSessionResults, clearSession, } from './session.js';
18
21
  // ─────────────────────────────────────────────────────────────────────────────
22
+ // Exit codes — a stable, documented contract CI pipelines can branch on.
23
+ //
24
+ // 0 success — all requests passed
25
+ // 1 one or more requests failed (assertions/errors), or --bail /
26
+ // --fail-on-error triggered — unchanged from prior releases
27
+ // 2 the runner could not execute the run at all: bad CLI args/flags,
28
+ // missing files, missing plugins, invalid env — a pipeline/config
29
+ // problem, not an API failure
30
+ //
31
+ // See CHANGELOG.md and docs.voiden.md/docs/developer-tools/voiden-runner/ci-cd
32
+ // ─────────────────────────────────────────────────────────────────────────────
33
+ const EXIT_SUCCESS = 0;
34
+ const EXIT_RUN_FAILURE = 1;
35
+ const EXIT_USAGE_ERROR = 2;
36
+ /** JSON output schema version — bump whenever a field is renamed, removed, or
37
+ * reinterpreted (adding a field is not a breaking change and does not need a bump). */
38
+ const JSON_SCHEMA_VERSION = '1';
39
+ // ─────────────────────────────────────────────────────────────────────────────
19
40
  // Helpers
20
41
  // ─────────────────────────────────────────────────────────────────────────────
21
- function loadEnvFile(envPath) {
22
- const content = readFileSync(envPath, 'utf-8');
23
- const env = {};
24
- const lines = content.split('\n');
25
- for (let i = 0; i < lines.length; i++) {
26
- const line = lines[i].trim();
27
- if (!line || line.startsWith('#'))
28
- continue;
29
- const eq = line.indexOf('=');
30
- if (eq === -1)
31
- throw new Error(`Malformed line ${i + 1} in .env file: missing "="`);
32
- const key = line.slice(0, eq).trim();
33
- const val = line.slice(eq + 1).trim().replace(/^["']|["']$/g, '');
34
- if (!key)
35
- throw new Error(`Malformed line ${i + 1} in .env file: empty key`);
36
- env[key] = val;
37
- }
38
- return env;
39
- }
40
42
  function formatBytes(bytes) {
41
43
  if (bytes < 1024)
42
44
  return `${bytes}B`;
@@ -49,50 +51,6 @@ function formatDuration(ms) {
49
51
  return `${ms}ms`;
50
52
  return `${(ms / 1000).toFixed(2)}s`;
51
53
  }
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
54
  // ─────────────────────────────────────────────────────────────────────────────
97
55
  // Spinner
98
56
  // ─────────────────────────────────────────────────────────────────────────────
@@ -266,18 +224,26 @@ function printRunSummary(results, totalMs) {
266
224
  console.log(DIVIDER);
267
225
  console.log();
268
226
  }
269
- function printRunSummaryJson(results, totalMs, activePlugins) {
227
+ /**
228
+ * Builds the `--json` / `--output-json` payload shape shared by `run` and
229
+ * `report generate`. `schemaVersion` is the stable contract external tooling
230
+ * codes against — see the exit-codes comment above for the versioning rule.
231
+ */
232
+ function buildJsonReport(results, extra = {}) {
270
233
  const passed = results.filter(r => r.result.success).length;
271
- const output = {
234
+ return {
235
+ schemaVersion: JSON_SCHEMA_VERSION,
272
236
  summary: {
273
237
  total: results.length,
274
238
  passed,
275
239
  failed: results.length - passed,
276
- totalDurationMs: totalMs,
277
- activePlugins,
240
+ ...extra,
278
241
  },
279
242
  requests: results.map(r => ({ file: r.file, ...r.result })),
280
243
  };
244
+ }
245
+ function printRunSummaryJson(results, totalMs, activePlugins) {
246
+ const output = buildJsonReport(results, { totalDurationMs: totalMs, activePlugins });
281
247
  console.log(JSON.stringify(output, null, 2));
282
248
  }
283
249
  /** Split CHANGELOG.md into per-version entries on "## " headers (newest first, matching file order). */
@@ -346,6 +312,65 @@ async function notifyPluginUpdates() {
346
312
  // Informational only — ignore failures (e.g. offline)
347
313
  }
348
314
  }
315
+ function scanProjectRequirements(files, cwd) {
316
+ // pluginId → version → files that declared it
317
+ const usages = new Map();
318
+ for (const file of files) {
319
+ let content;
320
+ try {
321
+ content = readFileSync(file, 'utf-8');
322
+ }
323
+ catch {
324
+ continue;
325
+ }
326
+ for (const block of parseVoidFile(content)) {
327
+ const pluginId = block.attrs?.pluginId;
328
+ const pluginVersion = block.attrs?.pluginVersion;
329
+ if (!pluginId || !pluginVersion)
330
+ continue;
331
+ if (!usages.has(pluginId))
332
+ usages.set(pluginId, new Map());
333
+ const versions = usages.get(pluginId);
334
+ if (!versions.has(pluginVersion))
335
+ versions.set(pluginVersion, new Set());
336
+ versions.get(pluginVersion).add(relative(cwd, file));
337
+ }
338
+ }
339
+ const issues = [];
340
+ for (const [pluginId, versions] of usages) {
341
+ for (const [version, fileSet] of versions) {
342
+ const installed = getInstalledPluginInfo(pluginId);
343
+ const status = classifyBlockVersion({ pluginId, pluginVersion: version, blockType: '' }, installed);
344
+ if (status === 'ok')
345
+ continue;
346
+ issues.push({ pluginId, requiredVersion: version, installedVersion: installed?.version, status, files: [...fileSet] });
347
+ }
348
+ }
349
+ return issues;
350
+ }
351
+ function printProjectStatus(issues) {
352
+ if (issues.length === 0)
353
+ return;
354
+ console.log();
355
+ console.log(chalk.yellow(` ⚠ ${issues.length} plugin${issues.length !== 1 ? 's' : ''} ${issues.length !== 1 ? "don't" : "doesn't"} match what this project needs`));
356
+ for (const issue of issues) {
357
+ const have = issue.status === 'not-installed' ? 'not installed'
358
+ : issue.status === 'disabled' ? 'installed but disabled'
359
+ : `v${issue.installedVersion} installed`;
360
+ console.log(chalk.gray(` ${chalk.bold(issue.pluginId.padEnd(24))} requires v${issue.requiredVersion} — ${have}`));
361
+ console.log(chalk.gray(` used in: ${issue.files.join(', ')}`));
362
+ }
363
+ console.log(chalk.gray(` Run: voiden-runner plugin install <name>@<version>`));
364
+ }
365
+ /** Best-effort — never throws, never blocks command output on failure. */
366
+ function notifyProjectStatus(files, cwd) {
367
+ try {
368
+ printProjectStatus(scanProjectRequirements(files, cwd));
369
+ }
370
+ catch {
371
+ // Informational only
372
+ }
373
+ }
349
374
  // ─────────────────────────────────────────────────────────────────────────────
350
375
  // CLI
351
376
  // ─────────────────────────────────────────────────────────────────────────────
@@ -368,7 +393,7 @@ program
368
393
  const changelogPath = resolve(join(dirname(fileURLToPath(import.meta.url)), '../CHANGELOG.md'));
369
394
  if (!existsSync(changelogPath)) {
370
395
  console.error(chalk.red(' ✗ No CHANGELOG.md found for this install.'));
371
- process.exit(1);
396
+ process.exit(EXIT_USAGE_ERROR);
372
397
  }
373
398
  const entries = parseChangelog(readFileSync(changelogPath, 'utf-8'));
374
399
  if (entries.length === 0) {
@@ -382,7 +407,7 @@ program
382
407
  if (!match) {
383
408
  console.error(chalk.red(` ✗ No changelog entry found for version "${version}".`));
384
409
  console.log(chalk.gray(` Available: ${entries.map(e => e.version).join(', ')}`));
385
- process.exit(1);
410
+ process.exit(EXIT_USAGE_ERROR);
386
411
  }
387
412
  toShow = [match];
388
413
  }
@@ -442,14 +467,14 @@ program
442
467
  const envPath = resolve(opts.env);
443
468
  if (!existsSync(envPath)) {
444
469
  console.error(chalk.red(`Env file not found: ${envPath}`));
445
- process.exit(1);
470
+ process.exit(EXIT_USAGE_ERROR);
446
471
  }
447
472
  try {
448
473
  Object.assign(env, loadEnvFile(envPath));
449
474
  }
450
475
  catch (err) {
451
476
  console.error(chalk.red(` ✗ ${err.message}`));
452
- process.exit(1);
477
+ process.exit(EXIT_USAGE_ERROR);
453
478
  }
454
479
  }
455
480
  // 2. Individual --env-var overrides
@@ -458,13 +483,13 @@ program
458
483
  const eq = pair.indexOf('=');
459
484
  if (eq === -1) {
460
485
  console.error(chalk.red(` ✗ Invalid --env-var format: "${pair}" (expected key=value)`));
461
- process.exit(1);
486
+ process.exit(EXIT_USAGE_ERROR);
462
487
  }
463
488
  const key = pair.slice(0, eq).trim();
464
489
  const val = pair.slice(eq + 1).trim().replace(/^["']|["']$/g, '');
465
490
  if (!key) {
466
491
  console.error(chalk.red(` ✗ Invalid --env-var format: "${pair}" (key cannot be empty)`));
467
- process.exit(1);
492
+ process.exit(EXIT_USAGE_ERROR);
468
493
  }
469
494
  env[key] = val;
470
495
  }
@@ -472,7 +497,7 @@ program
472
497
  const resolvedFiles = await resolveFiles(paths);
473
498
  if (resolvedFiles.length === 0) {
474
499
  console.error(chalk.red('No .void files found at the given path(s)'));
475
- process.exit(1);
500
+ process.exit(EXIT_USAGE_ERROR);
476
501
  }
477
502
  // --stop-on-failure is a CI-friendly alias for --bail
478
503
  const stopOnFailure = opts.bail || opts.stopOnFailure;
@@ -490,11 +515,11 @@ program
490
515
  if (opts.mail || opts.mailTo) {
491
516
  if (!mailTo) {
492
517
  console.error(chalk.red(' ✗ Mail error: no recipient found. Please provide --mail-to or set VOIDEN_MAIL_TO.'));
493
- process.exit(1);
518
+ process.exit(EXIT_USAGE_ERROR);
494
519
  }
495
520
  if (!smtpHost) {
496
521
  console.error(chalk.red(' ✗ Mail keys are missing. Please provide SMTP configuration (VOIDEN_SMTP_HOST).'));
497
- process.exit(1);
522
+ process.exit(EXIT_USAGE_ERROR);
498
523
  }
499
524
  }
500
525
  const runStart = Date.now();
@@ -600,16 +625,7 @@ program
600
625
  // ── Output JSON to file (before mail so it can be attached) ──────────────
601
626
  let savedJsonPath;
602
627
  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
- };
628
+ const jsonData = buildJsonReport(allResults, { totalDurationMs: totalMs, activePlugins });
613
629
  try {
614
630
  mkdirSync(dirname(opts.outputJson), { recursive: true });
615
631
  writeFileSync(opts.outputJson, JSON.stringify(jsonData, null, 2) + '\n', 'utf-8');
@@ -650,10 +666,12 @@ program
650
666
  console.log(chalk.gray(' (use this exit code in your shell script to abort on failure)'));
651
667
  console.log();
652
668
  }
653
- // Surface plugin update notices — skipped in --json mode so output stays machine-readable
654
- if (!opts.json)
669
+ // Surface plugin update / project-requirements notices — skipped in --json mode so output stays machine-readable
670
+ if (!opts.json) {
655
671
  await notifyPluginUpdates();
656
- process.exit(shouldFail ? 1 : 0);
672
+ notifyProjectStatus(resolvedFiles, process.cwd());
673
+ }
674
+ process.exit(shouldFail ? EXIT_RUN_FAILURE : EXIT_SUCCESS);
657
675
  });
658
676
  // ── voiden-runner session ─────────────────────────────────────────────────────
659
677
  const sessionCmd = program
@@ -768,7 +786,7 @@ reportCmd
768
786
  const results = loadSessionResults();
769
787
  if (results.length === 0) {
770
788
  console.error(chalk.red(' ✗ No results found in session. Run some .void files first.'));
771
- process.exit(1);
789
+ process.exit(EXIT_USAGE_ERROR);
772
790
  }
773
791
  // Load optional .env for report SMTP settings
774
792
  const env = { ...process.env };
@@ -787,12 +805,12 @@ reportCmd
787
805
  if (opts.mail || opts.mailTo) {
788
806
  if (!mailTo) {
789
807
  console.error(chalk.red(' ✗ Mail error: no recipient found. Please provide --mail-to or set VOIDEN_MAIL_TO.'));
790
- process.exit(1);
808
+ process.exit(EXIT_USAGE_ERROR);
791
809
  }
792
810
  const smtpHost = opts.smtpHost || env.VOIDEN_SMTP_HOST;
793
811
  if (!smtpHost) {
794
812
  console.error(chalk.red(' ✗ Mail keys are missing. Please provide SMTP configuration (VOIDEN_SMTP_HOST).'));
795
- process.exit(1);
813
+ process.exit(EXIT_USAGE_ERROR);
796
814
  }
797
815
  }
798
816
  if (!opts.csv && !opts.outputJson && !mailTo) {
@@ -811,14 +829,7 @@ reportCmd
811
829
  }
812
830
  let savedJsonPath;
813
831
  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
- };
832
+ const jsonData = buildJsonReport(results);
822
833
  try {
823
834
  mkdirSync(dirname(opts.outputJson), { recursive: true });
824
835
  writeFileSync(opts.outputJson, JSON.stringify(jsonData, null, 2) + '\n', 'utf-8');
@@ -839,7 +850,7 @@ reportCmd
839
850
  if (!smtpHost) {
840
851
  console.error(chalk.red(' ✗ SMTP configuration required for email reports.'));
841
852
  console.log(chalk.gray(' Set VOIDEN_SMTP_HOST in your environment or use --smtp-host.'));
842
- process.exit(1);
853
+ process.exit(EXIT_USAGE_ERROR);
843
854
  }
844
855
  console.log(chalk.gray(` ↑ Sending session report to ${mailTo} …`));
845
856
  try {
@@ -862,6 +873,14 @@ reportCmd
862
873
  }
863
874
  }
864
875
  });
876
+ // Splits a `name` or `name@version` CLI arg. Plugin ids are plain slugs (no
877
+ // leading `@`), so splitting on the first `@` is unambiguous.
878
+ function parsePluginTarget(raw) {
879
+ const at = raw.indexOf('@');
880
+ if (at <= 0)
881
+ return { name: raw };
882
+ return { name: raw.slice(0, at), pinVersion: raw.slice(at + 1) };
883
+ }
865
884
  // ── voiden-runner plugin ──────────────────────────────────────────────────────
866
885
  const pluginCmd = program
867
886
  .command('plugin')
@@ -870,37 +889,44 @@ const pluginCmd = program
870
889
  pluginCmd
871
890
  .command('install [names...]')
872
891
  .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' +
892
+ ' --all installs all core plugins only. Community plugins must be installed by name.\n' +
893
+ ' Pin an exact version with name@version (e.g. after `voiden-runner lock`, or to\n' +
894
+ ' match a "Block ... requires plugin X vY" error).\n\n' +
874
895
  ' Examples:\n' +
875
896
  ' voiden-runner plugin install --all\n' +
876
897
  ' voiden-runner plugin install voiden-scripting\n' +
877
- ' voiden-runner plugin install apyhub-explorer\n')
898
+ ' voiden-runner plugin install voiden-rest-api@1.4.7\n')
878
899
  .option('--all', 'Install all core plugins (community plugins must be installed by name)')
879
- .action(async (names, opts) => {
900
+ .action(async (rawNames, opts) => {
880
901
  const corePlugins = await getCorePlugins();
881
902
  const communityPlugins = await fetchCommunityPlugins();
882
903
  const targets = opts.all
883
- ? corePlugins.map(p => p.name)
884
- : names;
904
+ ? corePlugins.map(p => ({ name: p.name }))
905
+ : rawNames.map(parsePluginTarget);
885
906
  if (targets.length === 0) {
886
907
  console.error(chalk.red('Specify plugin name(s) or use --all'));
887
908
  console.log(chalk.gray(' Core: ' + corePlugins.map(p => p.name).join(', ')));
888
909
  if (communityPlugins.length > 0) {
889
910
  console.log(chalk.gray(' Community (install by name): ' + communityPlugins.map(p => p.id).join(', ')));
890
911
  }
891
- process.exit(1);
912
+ process.exit(EXIT_USAGE_ERROR);
892
913
  }
893
914
  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) {
915
+ for (const { name, pinVersion } of targets) {
916
+ const foundCoreDef = await findPlugin(name);
917
+ const foundCommDef = !foundCoreDef ? findCommunityPlugin(name, communityPlugins) : undefined;
918
+ if (!foundCoreDef && !foundCommDef) {
898
919
  console.log(chalk.yellow(` ⚠ Unknown plugin "${name}" — skipped`));
899
920
  continue;
900
921
  }
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)) {
922
+ // A pinned version overrides the registry's "latest" default this is what
923
+ // makes the fix-it command in version-mismatch errors ("plugin install x@y")
924
+ // actually able to install the exact version a file declares.
925
+ const coreDef = foundCoreDef && pinVersion ? { ...foundCoreDef, version: pinVersion } : foundCoreDef;
926
+ const commDef = foundCommDef && pinVersion ? { ...foundCommDef, version: pinVersion } : foundCommDef;
927
+ // Core plugins: only download if not already bundled/cached — unless a
928
+ // specific version was pinned, in which case always fetch that version.
929
+ if (coreDef && (pinVersion || !hasCoreRunner(name))) {
904
930
  process.stdout.write(` ↓ Downloading runner for ${chalk.bold(name)} …`);
905
931
  try {
906
932
  const ok = await downloadCoreRunner(coreDef.name, coreDef.repo, coreDef.runnerAsset, coreDef.version, false);
@@ -938,6 +964,13 @@ pluginCmd
938
964
  console.log(chalk.green(` ✓ Installed`) + chalk.bold(` ${name}`) + chalk.gray(` — ${description}`));
939
965
  installedCount++;
940
966
  }
967
+ else if (pinVersion) {
968
+ // Re-running install with an explicit pin (e.g. to fix a version-mismatch)
969
+ // should still record the newly-downloaded version even if already "installed".
970
+ setPluginVersion(name, coreDef?.version ?? commDef?.version ?? pinVersion);
971
+ console.log(chalk.green(` ✓ Installed`) + chalk.bold(` ${name}@${pinVersion}`));
972
+ installedCount++;
973
+ }
941
974
  else {
942
975
  console.log(chalk.gray(` · Already installed`) + ` ${name}`);
943
976
  }
@@ -1030,16 +1063,24 @@ pluginCmd
1030
1063
  ' voiden-runner plugin uninstall apyhub-explorer voiden-scripting\n' +
1031
1064
  ' voiden-runner plugin uninstall --all\n')
1032
1065
  .option('--all', 'Uninstall all installed plugins (core and community)')
1033
- .action((names, opts) => {
1034
- const targets = opts.all ? Object.keys(readStore().installedPlugins) : names;
1066
+ .action(async (names, opts) => {
1067
+ // Core plugins are bundled and enabled by default — they may never have
1068
+ // an explicit store record even though they're clearly active, so --all
1069
+ // (and named uninstalls of a bundled plugin) must include the full core
1070
+ // registry, not just names that already happen to have a store record.
1071
+ const corePlugins = await getCorePlugins();
1072
+ const coreNames = new Set(corePlugins.map(p => p.name));
1073
+ const targets = opts.all
1074
+ ? [...new Set([...coreNames, ...getAllInstalledPlugins().map(p => p.name)])]
1075
+ : names;
1035
1076
  if (targets.length === 0) {
1036
1077
  console.error(chalk.red(' Specify plugin name(s) or use --all'));
1037
- process.exit(1);
1078
+ process.exit(EXIT_USAGE_ERROR);
1038
1079
  return;
1039
1080
  }
1040
1081
  let removedCount = 0;
1041
1082
  for (const name of targets) {
1042
- const removed = uninstallPlugin(name);
1083
+ const removed = uninstallPlugin(name, coreNames.has(name));
1043
1084
  if (removed) {
1044
1085
  console.log(chalk.green(` ✓ Uninstalled`) + ` ${name}`);
1045
1086
  removedCount++;
@@ -1064,9 +1105,11 @@ pluginCmd
1064
1105
  .action(async (name, opts) => {
1065
1106
  if (opts.all) {
1066
1107
  const store = readStore();
1067
- // Re-enable all explicitly disabled plugins (core + community)
1108
+ // Re-enable all explicitly disabled plugins (core + community) — but not
1109
+ // uninstalled ones; bringing those back requires an explicit `plugin
1110
+ // install`, not a blanket --all enable.
1068
1111
  const disabled = Object.entries(store.installedPlugins)
1069
- .filter(([, r]) => !r.enabled)
1112
+ .filter(([, r]) => !r.enabled && !r.uninstalled)
1070
1113
  .map(([n]) => n);
1071
1114
  // Also ensure all core plugins that were never in the store are treated as enabled (default)
1072
1115
  const disabledCoreNotInStore = [];
@@ -1083,14 +1126,14 @@ pluginCmd
1083
1126
  }
1084
1127
  if (!name) {
1085
1128
  console.error(chalk.red(' Specify a plugin name or use --all'));
1086
- process.exit(1);
1129
+ process.exit(EXIT_USAGE_ERROR);
1087
1130
  }
1088
1131
  const communityPlugins = await fetchCommunityPlugins();
1089
1132
  const commDef = findCommunityPlugin(name, communityPlugins);
1090
1133
  if (commDef && !hasCommunityRunner(name)) {
1091
1134
  console.log(chalk.red(` ✗ Cannot enable "${name}" — runner not installed`));
1092
1135
  console.log(chalk.gray(` Run: voiden-runner plugin install ${name}`));
1093
- process.exit(1);
1136
+ process.exit(EXIT_USAGE_ERROR);
1094
1137
  }
1095
1138
  setPluginEnabled(name, true);
1096
1139
  console.log(chalk.green(` ✓ Enabled`) + ` ${name}`);
@@ -1125,7 +1168,7 @@ pluginCmd
1125
1168
  }
1126
1169
  if (!name) {
1127
1170
  console.error(chalk.red(' Specify a plugin name or use --all'));
1128
- process.exit(1);
1171
+ process.exit(EXIT_USAGE_ERROR);
1129
1172
  return;
1130
1173
  }
1131
1174
  setPluginEnabled(name, false);
@@ -1155,10 +1198,16 @@ pluginCmd
1155
1198
  console.log(DIVIDER);
1156
1199
  for (const def of corePlugins) {
1157
1200
  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');
1201
+ let statusBadge;
1202
+ if (record?.uninstalled) {
1203
+ statusBadge = chalk.gray(' not installed');
1204
+ }
1205
+ else if (record !== undefined && !record.enabled) {
1206
+ statusBadge = chalk.yellow(' · disabled');
1207
+ }
1208
+ else {
1209
+ statusBadge = chalk.green(' ✓ enabled');
1210
+ }
1162
1211
  console.log(` ${chalk.bold(def.name.padEnd(24))}${statusBadge}${updateBadge(def.name, def.version)}`);
1163
1212
  console.log(chalk.gray(` ${def.description}`));
1164
1213
  }
@@ -1211,5 +1260,87 @@ pluginCmd
1211
1260
  }
1212
1261
  console.log();
1213
1262
  });
1263
+ // ── voiden-runner mcp ─────────────────────────────────────────────────────────
1264
+ //
1265
+ // Enables the AI-agent loop for CLI-only users (no Voiden app installed):
1266
+ // registers @voiden/mcp-server with Claude Code / Codex, and installs a
1267
+ // standalone skill teaching the run/verify/write-back workflow. The Voiden
1268
+ // app's own Settings toggle does the equivalent for desktop users, reusing
1269
+ // the same registration helpers from mcpInstall.ts.
1270
+ function resolveMcpTargets(opts) {
1271
+ // Default to both when neither flag is given — a single command should be
1272
+ // enough to "just enable this".
1273
+ if (!opts.claude && !opts.codex)
1274
+ return { claude: true, codex: true };
1275
+ return { claude: Boolean(opts.claude), codex: Boolean(opts.codex) };
1276
+ }
1277
+ const mcpCmd = program
1278
+ .command('mcp')
1279
+ .description('Enable AI-agent integration — registers @voiden/mcp-server and installs a run/verify skill');
1280
+ mcpCmd
1281
+ .command('install')
1282
+ .description('Register @voiden/mcp-server with Claude Code and/or Codex, and install a skill teaching the run/verify/write-back loop.\n\n' +
1283
+ ' Examples:\n' +
1284
+ ' voiden-runner mcp install # both Claude Code and Codex\n' +
1285
+ ' voiden-runner mcp install --claude # Claude Code only\n' +
1286
+ ' voiden-runner mcp install -p ./my-project # register against a specific project dir (default: cwd)\n' +
1287
+ ' voiden-runner mcp install --local-server ./dist/index.js # before publishing: point at a local build instead of npx\n')
1288
+ .option('--claude', 'Install for Claude Code only')
1289
+ .option('--codex', 'Install for Codex only')
1290
+ .option('-p, --project <path>', 'Project directory to register the MCP server against', '.')
1291
+ .option('--local-server <path>', 'Use `node <path>` instead of `npx -y @voiden/mcp-server` — for testing against a local build before it\'s published')
1292
+ .action((opts) => {
1293
+ const targets = resolveMcpTargets(opts);
1294
+ const serverCommand = opts.localServer
1295
+ ? { command: 'node', args: [resolve(opts.localServer), resolve(opts.project)] }
1296
+ : undefined;
1297
+ const installed = installMcpIntegration(opts.project, targets, MCP_SKILL_MARKDOWN, serverCommand);
1298
+ if (installed.length === 0) {
1299
+ console.log(chalk.yellow(' Nothing to install.'));
1300
+ return;
1301
+ }
1302
+ console.log();
1303
+ for (const target of installed) {
1304
+ console.log(chalk.green(` ✓ ${target === 'claude' ? 'Claude Code' : 'Codex'}`) + chalk.gray(` — skill installed, @voiden/mcp-server registered for ${resolve(opts.project)}`));
1305
+ }
1306
+ if (serverCommand) {
1307
+ console.log(chalk.gray(` Using local build: node ${serverCommand.args[0]}`));
1308
+ }
1309
+ console.log();
1310
+ console.log(chalk.gray(' Restart Claude Code / Codex (or run /mcp) to pick up the new server.'));
1311
+ });
1312
+ mcpCmd
1313
+ .command('uninstall')
1314
+ .description('Remove the MCP server registration and skill installed by `mcp install`')
1315
+ .option('--claude', 'Remove Claude Code integration only')
1316
+ .option('--codex', 'Remove Codex integration only')
1317
+ .option('-p, --project <path>', 'Project directory to unregister the MCP server from', '.')
1318
+ .action((opts) => {
1319
+ const targets = resolveMcpTargets(opts);
1320
+ const removed = uninstallMcpIntegration(opts.project, targets);
1321
+ if (removed.length === 0) {
1322
+ console.log(chalk.yellow(' Nothing to remove.'));
1323
+ return;
1324
+ }
1325
+ for (const target of removed) {
1326
+ console.log(chalk.green(` ✓ Removed`) + chalk.gray(` ${target === 'claude' ? 'Claude Code' : 'Codex'} integration`));
1327
+ }
1328
+ });
1329
+ mcpCmd
1330
+ .command('status')
1331
+ .description('Show whether the MCP server + skill are installed for this project')
1332
+ .option('-p, --project <path>', 'Project directory to check', '.')
1333
+ .action((opts) => {
1334
+ const status = getMcpStatus(opts.project);
1335
+ console.log();
1336
+ console.log(chalk.bold(' Claude Code'));
1337
+ console.log(` skill installed: ${status.claude.skillInstalled ? chalk.green('yes') : chalk.gray('no')}`);
1338
+ console.log(` server registered: ${status.claude.serverRegistered ? chalk.green('yes') : chalk.gray('no')}`);
1339
+ console.log();
1340
+ console.log(chalk.bold(' Codex'));
1341
+ console.log(` skill installed: ${status.codex.skillInstalled ? chalk.green('yes') : chalk.gray('no')}`);
1342
+ console.log(` server registered: ${status.codex.serverRegistered ? chalk.green('yes') : chalk.gray('no')}`);
1343
+ console.log();
1344
+ });
1214
1345
  program.parse();
1215
1346
  //# sourceMappingURL=index.js.map