@voiden/runner 2.1.0 → 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 (65) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/bundled-runners/package.json +3 -0
  3. package/bundled-runners/simple-assertions-runner.js +1 -0
  4. package/bundled-runners/versions.json +9 -0
  5. package/bundled-runners/voiden-advanced-auth-runner.js +1 -0
  6. package/bundled-runners/voiden-faker-runner.js +9 -0
  7. package/bundled-runners/voiden-graphql-runner.js +1 -0
  8. package/bundled-runners/voiden-rest-api-runner.js +1 -0
  9. package/bundled-runners/voiden-scripting-runner.js +397 -0
  10. package/bundled-runners/voiden-sockets-grpcs-runner.js +1 -0
  11. package/dist/discovery.d.ts +11 -0
  12. package/dist/discovery.d.ts.map +1 -0
  13. package/dist/discovery.js +54 -0
  14. package/dist/discovery.js.map +1 -0
  15. package/dist/index.js +384 -112
  16. package/dist/index.js.map +1 -1
  17. package/dist/lib.d.ts +19 -0
  18. package/dist/lib.d.ts.map +1 -0
  19. package/dist/lib.js +19 -0
  20. package/dist/lib.js.map +1 -0
  21. package/dist/mcpInstall.d.ts +48 -0
  22. package/dist/mcpInstall.d.ts.map +1 -0
  23. package/dist/mcpInstall.js +194 -0
  24. package/dist/mcpInstall.js.map +1 -0
  25. package/dist/plugins/community.d.ts.map +1 -1
  26. package/dist/plugins/community.js +9 -2
  27. package/dist/plugins/community.js.map +1 -1
  28. package/dist/plugins/loader.d.ts +1 -0
  29. package/dist/plugins/loader.d.ts.map +1 -1
  30. package/dist/plugins/loader.js +32 -18
  31. package/dist/plugins/loader.js.map +1 -1
  32. package/dist/plugins/registry.d.ts +11 -0
  33. package/dist/plugins/registry.d.ts.map +1 -1
  34. package/dist/plugins/registry.js +63 -10
  35. package/dist/plugins/registry.js.map +1 -1
  36. package/dist/plugins/registryCache.d.ts +2 -0
  37. package/dist/plugins/registryCache.d.ts.map +1 -1
  38. package/dist/plugins/registryCache.js.map +1 -1
  39. package/dist/plugins/store.d.ts +28 -1
  40. package/dist/plugins/store.d.ts.map +1 -1
  41. package/dist/plugins/store.js +46 -9
  42. package/dist/plugins/store.js.map +1 -1
  43. package/dist/plugins/updateCheck.d.ts +9 -4
  44. package/dist/plugins/updateCheck.d.ts.map +1 -1
  45. package/dist/plugins/updateCheck.js +29 -15
  46. package/dist/plugins/updateCheck.js.map +1 -1
  47. package/dist/plugins/versionInfo.d.ts +15 -0
  48. package/dist/plugins/versionInfo.d.ts.map +1 -0
  49. package/dist/plugins/versionInfo.js +18 -0
  50. package/dist/plugins/versionInfo.js.map +1 -0
  51. package/dist/resultBlock.d.ts +31 -0
  52. package/dist/resultBlock.d.ts.map +1 -0
  53. package/dist/resultBlock.js +121 -0
  54. package/dist/resultBlock.js.map +1 -0
  55. package/dist/runner.d.ts +11 -0
  56. package/dist/runner.d.ts.map +1 -1
  57. package/dist/runner.js +61 -2
  58. package/dist/runner.js.map +1 -1
  59. package/dist/runtimeVars.js +3 -3
  60. package/dist/runtimeVars.js.map +1 -1
  61. package/dist/skillContent.d.ts +8 -0
  62. package/dist/skillContent.d.ts.map +1 -0
  63. package/dist/skillContent.js +42 -0
  64. package/dist/skillContent.js.map +1 -0
  65. package/package.json +13 -2
package/dist/index.js CHANGED
@@ -1,21 +1,44 @@
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';
12
- import { getCorePlugins, findPlugin } from './plugins/registry.js';
12
+ import { getCorePlugins, findPlugin, hasCoreRunner } from './plugins/registry.js';
13
13
  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,20 +245,71 @@ 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
  }
270
+ /** Split CHANGELOG.md into per-version entries on "## " headers (newest first, matching file order). */
271
+ function parseChangelog(content) {
272
+ const lines = content.split('\n');
273
+ const entries = [];
274
+ let current = null;
275
+ for (const line of lines) {
276
+ const headerMatch = line.match(/^##\s+(\S+)(?:\s+-\s+(.+))?$/);
277
+ if (headerMatch) {
278
+ if (current)
279
+ entries.push(current);
280
+ current = { version: headerMatch[1], date: headerMatch[2]?.trim(), body: [] };
281
+ continue;
282
+ }
283
+ if (current)
284
+ current.body.push(line);
285
+ }
286
+ if (current)
287
+ entries.push(current);
288
+ return entries;
289
+ }
290
+ function renderChangelogEntry(entry) {
291
+ const header = entry.date ? `${entry.version} ${chalk.gray(entry.date)}` : entry.version;
292
+ console.log(chalk.bold.white(` ${header}`));
293
+ console.log(DIVIDER);
294
+ for (const line of entry.body) {
295
+ const trimmed = line.trim();
296
+ if (!trimmed)
297
+ continue;
298
+ const sectionMatch = trimmed.match(/^###\s+(.+)$/);
299
+ if (sectionMatch) {
300
+ console.log();
301
+ console.log(chalk.bold.cyan(` ${sectionMatch[1]}`));
302
+ continue;
303
+ }
304
+ const bulletMatch = trimmed.match(/^-\s+(.+)$/);
305
+ if (bulletMatch) {
306
+ console.log(chalk.gray(` · `) + bulletMatch[1]);
307
+ continue;
308
+ }
309
+ console.log(` ${trimmed}`);
310
+ }
311
+ console.log();
312
+ }
283
313
  // ─────────────────────────────────────────────────────────────────────────────
284
314
  // Plugin update notices — surfaced after `run` and `plugin` commands so users
285
315
  // learn about newer registry versions without having to run `plugin list`.
@@ -303,6 +333,65 @@ async function notifyPluginUpdates() {
303
333
  // Informational only — ignore failures (e.g. offline)
304
334
  }
305
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
+ }
306
395
  // ─────────────────────────────────────────────────────────────────────────────
307
396
  // CLI
308
397
  // ─────────────────────────────────────────────────────────────────────────────
@@ -312,6 +401,47 @@ program
312
401
  .name('voiden-runner')
313
402
  .description('Run .void files headlessly — REST, WebSocket, and gRPC')
314
403
  .version(pkg.version);
404
+ // ── voiden-runner changelog ───────────────────────────────────────────────────
405
+ program
406
+ .command('changelog [version]')
407
+ .description('Show release notes for voiden-runner — versioned independently of the desktop app\n\n' +
408
+ ' Examples:\n' +
409
+ ' voiden-runner changelog # full history\n' +
410
+ ' voiden-runner changelog --latest # most recent release only\n' +
411
+ ' voiden-runner changelog 2.1.0 # a specific version\n')
412
+ .option('--latest', 'Show only the most recent release')
413
+ .action((version, opts) => {
414
+ const changelogPath = resolve(join(dirname(fileURLToPath(import.meta.url)), '../CHANGELOG.md'));
415
+ if (!existsSync(changelogPath)) {
416
+ console.error(chalk.red(' ✗ No CHANGELOG.md found for this install.'));
417
+ process.exit(EXIT_USAGE_ERROR);
418
+ }
419
+ const entries = parseChangelog(readFileSync(changelogPath, 'utf-8'));
420
+ if (entries.length === 0) {
421
+ console.log(chalk.gray(' Changelog is empty.'));
422
+ return;
423
+ }
424
+ let toShow = entries;
425
+ if (version) {
426
+ const normalized = version.replace(/^v/, '');
427
+ const match = entries.find(e => e.version.replace(/^v/, '') === normalized);
428
+ if (!match) {
429
+ console.error(chalk.red(` ✗ No changelog entry found for version "${version}".`));
430
+ console.log(chalk.gray(` Available: ${entries.map(e => e.version).join(', ')}`));
431
+ process.exit(EXIT_USAGE_ERROR);
432
+ }
433
+ toShow = [match];
434
+ }
435
+ else if (opts.latest) {
436
+ toShow = [entries[0]];
437
+ }
438
+ console.log();
439
+ console.log(chalk.bold.white(' voiden-runner changelog'));
440
+ console.log();
441
+ for (const entry of toShow) {
442
+ renderChangelogEntry(entry);
443
+ }
444
+ });
315
445
  // ── voiden-runner run ─────────────────────────────────────────────────────────
316
446
  program
317
447
  .command('run <paths...>')
@@ -358,14 +488,14 @@ program
358
488
  const envPath = resolve(opts.env);
359
489
  if (!existsSync(envPath)) {
360
490
  console.error(chalk.red(`Env file not found: ${envPath}`));
361
- process.exit(1);
491
+ process.exit(EXIT_USAGE_ERROR);
362
492
  }
363
493
  try {
364
494
  Object.assign(env, loadEnvFile(envPath));
365
495
  }
366
496
  catch (err) {
367
497
  console.error(chalk.red(` ✗ ${err.message}`));
368
- process.exit(1);
498
+ process.exit(EXIT_USAGE_ERROR);
369
499
  }
370
500
  }
371
501
  // 2. Individual --env-var overrides
@@ -374,13 +504,13 @@ program
374
504
  const eq = pair.indexOf('=');
375
505
  if (eq === -1) {
376
506
  console.error(chalk.red(` ✗ Invalid --env-var format: "${pair}" (expected key=value)`));
377
- process.exit(1);
507
+ process.exit(EXIT_USAGE_ERROR);
378
508
  }
379
509
  const key = pair.slice(0, eq).trim();
380
510
  const val = pair.slice(eq + 1).trim().replace(/^["']|["']$/g, '');
381
511
  if (!key) {
382
512
  console.error(chalk.red(` ✗ Invalid --env-var format: "${pair}" (key cannot be empty)`));
383
- process.exit(1);
513
+ process.exit(EXIT_USAGE_ERROR);
384
514
  }
385
515
  env[key] = val;
386
516
  }
@@ -388,7 +518,7 @@ program
388
518
  const resolvedFiles = await resolveFiles(paths);
389
519
  if (resolvedFiles.length === 0) {
390
520
  console.error(chalk.red('No .void files found at the given path(s)'));
391
- process.exit(1);
521
+ process.exit(EXIT_USAGE_ERROR);
392
522
  }
393
523
  // --stop-on-failure is a CI-friendly alias for --bail
394
524
  const stopOnFailure = opts.bail || opts.stopOnFailure;
@@ -406,11 +536,11 @@ program
406
536
  if (opts.mail || opts.mailTo) {
407
537
  if (!mailTo) {
408
538
  console.error(chalk.red(' ✗ Mail error: no recipient found. Please provide --mail-to or set VOIDEN_MAIL_TO.'));
409
- process.exit(1);
539
+ process.exit(EXIT_USAGE_ERROR);
410
540
  }
411
541
  if (!smtpHost) {
412
542
  console.error(chalk.red(' ✗ Mail keys are missing. Please provide SMTP configuration (VOIDEN_SMTP_HOST).'));
413
- process.exit(1);
543
+ process.exit(EXIT_USAGE_ERROR);
414
544
  }
415
545
  }
416
546
  const runStart = Date.now();
@@ -516,16 +646,7 @@ program
516
646
  // ── Output JSON to file (before mail so it can be attached) ──────────────
517
647
  let savedJsonPath;
518
648
  if (opts.outputJson) {
519
- const jsonData = {
520
- summary: {
521
- total: allResults.length,
522
- passed: allResults.filter(r => r.result.success).length,
523
- failed: allResults.filter(r => !r.result.success).length,
524
- totalDurationMs: totalMs,
525
- activePlugins,
526
- },
527
- requests: allResults.map(r => ({ file: r.file, ...r.result })),
528
- };
649
+ const jsonData = buildJsonReport(allResults, { totalDurationMs: totalMs, activePlugins });
529
650
  try {
530
651
  mkdirSync(dirname(opts.outputJson), { recursive: true });
531
652
  writeFileSync(opts.outputJson, JSON.stringify(jsonData, null, 2) + '\n', 'utf-8');
@@ -566,10 +687,12 @@ program
566
687
  console.log(chalk.gray(' (use this exit code in your shell script to abort on failure)'));
567
688
  console.log();
568
689
  }
569
- // Surface plugin update notices — skipped in --json mode so output stays machine-readable
570
- if (!opts.json)
690
+ // Surface plugin update / project-requirements notices — skipped in --json mode so output stays machine-readable
691
+ if (!opts.json) {
571
692
  await notifyPluginUpdates();
572
- process.exit(shouldFail ? 1 : 0);
693
+ notifyProjectStatus(resolvedFiles, process.cwd());
694
+ }
695
+ process.exit(shouldFail ? EXIT_RUN_FAILURE : EXIT_SUCCESS);
573
696
  });
574
697
  // ── voiden-runner session ─────────────────────────────────────────────────────
575
698
  const sessionCmd = program
@@ -684,7 +807,7 @@ reportCmd
684
807
  const results = loadSessionResults();
685
808
  if (results.length === 0) {
686
809
  console.error(chalk.red(' ✗ No results found in session. Run some .void files first.'));
687
- process.exit(1);
810
+ process.exit(EXIT_USAGE_ERROR);
688
811
  }
689
812
  // Load optional .env for report SMTP settings
690
813
  const env = { ...process.env };
@@ -703,12 +826,12 @@ reportCmd
703
826
  if (opts.mail || opts.mailTo) {
704
827
  if (!mailTo) {
705
828
  console.error(chalk.red(' ✗ Mail error: no recipient found. Please provide --mail-to or set VOIDEN_MAIL_TO.'));
706
- process.exit(1);
829
+ process.exit(EXIT_USAGE_ERROR);
707
830
  }
708
831
  const smtpHost = opts.smtpHost || env.VOIDEN_SMTP_HOST;
709
832
  if (!smtpHost) {
710
833
  console.error(chalk.red(' ✗ Mail keys are missing. Please provide SMTP configuration (VOIDEN_SMTP_HOST).'));
711
- process.exit(1);
834
+ process.exit(EXIT_USAGE_ERROR);
712
835
  }
713
836
  }
714
837
  if (!opts.csv && !opts.outputJson && !mailTo) {
@@ -727,14 +850,7 @@ reportCmd
727
850
  }
728
851
  let savedJsonPath;
729
852
  if (opts.outputJson) {
730
- const jsonData = {
731
- summary: {
732
- total: results.length,
733
- passed: results.filter(r => r.result.success).length,
734
- failed: results.filter(r => !r.result.success).length,
735
- },
736
- requests: results.map(r => ({ file: r.file, ...r.result })),
737
- };
853
+ const jsonData = buildJsonReport(results);
738
854
  try {
739
855
  mkdirSync(dirname(opts.outputJson), { recursive: true });
740
856
  writeFileSync(opts.outputJson, JSON.stringify(jsonData, null, 2) + '\n', 'utf-8');
@@ -755,7 +871,7 @@ reportCmd
755
871
  if (!smtpHost) {
756
872
  console.error(chalk.red(' ✗ SMTP configuration required for email reports.'));
757
873
  console.log(chalk.gray(' Set VOIDEN_SMTP_HOST in your environment or use --smtp-host.'));
758
- process.exit(1);
874
+ process.exit(EXIT_USAGE_ERROR);
759
875
  }
760
876
  console.log(chalk.gray(` ↑ Sending session report to ${mailTo} …`));
761
877
  try {
@@ -778,6 +894,14 @@ reportCmd
778
894
  }
779
895
  }
780
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
+ }
781
905
  // ── voiden-runner plugin ──────────────────────────────────────────────────────
782
906
  const pluginCmd = program
783
907
  .command('plugin')
@@ -786,34 +910,58 @@ const pluginCmd = program
786
910
  pluginCmd
787
911
  .command('install [names...]')
788
912
  .description('Install one or more plugins, or all core plugins\n\n' +
789
- ' --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' +
790
916
  ' Examples:\n' +
791
917
  ' voiden-runner plugin install --all\n' +
792
918
  ' voiden-runner plugin install voiden-scripting\n' +
793
- ' voiden-runner plugin install apyhub-explorer\n')
919
+ ' voiden-runner plugin install voiden-rest-api@1.4.7\n')
794
920
  .option('--all', 'Install all core plugins (community plugins must be installed by name)')
795
- .action(async (names, opts) => {
921
+ .action(async (rawNames, opts) => {
796
922
  const corePlugins = await getCorePlugins();
797
923
  const communityPlugins = await fetchCommunityPlugins();
798
924
  const targets = opts.all
799
- ? corePlugins.map(p => p.name)
800
- : names;
925
+ ? corePlugins.map(p => ({ name: p.name }))
926
+ : rawNames.map(parsePluginTarget);
801
927
  if (targets.length === 0) {
802
928
  console.error(chalk.red('Specify plugin name(s) or use --all'));
803
929
  console.log(chalk.gray(' Core: ' + corePlugins.map(p => p.name).join(', ')));
804
930
  if (communityPlugins.length > 0) {
805
931
  console.log(chalk.gray(' Community (install by name): ' + communityPlugins.map(p => p.id).join(', ')));
806
932
  }
807
- process.exit(1);
933
+ process.exit(EXIT_USAGE_ERROR);
808
934
  }
809
935
  let installedCount = 0;
810
- for (const name of targets) {
811
- const coreDef = await findPlugin(name);
812
- const commDef = !coreDef ? findCommunityPlugin(name, communityPlugins) : undefined;
813
- 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) {
814
940
  console.log(chalk.yellow(` ⚠ Unknown plugin "${name}" — skipped`));
815
941
  continue;
816
942
  }
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))) {
951
+ process.stdout.write(` ↓ Downloading runner for ${chalk.bold(name)} …`);
952
+ try {
953
+ const ok = await downloadCoreRunner(coreDef.name, coreDef.repo, coreDef.runnerAsset, coreDef.version, false);
954
+ process.stdout.write('\r' + ' '.repeat(60) + '\r');
955
+ if (!ok) {
956
+ console.log(chalk.red(` ✗ No "${coreDef.runnerAsset}" asset in release v${coreDef.version} for "${name}"`));
957
+ continue;
958
+ }
959
+ }
960
+ catch (err) {
961
+ process.stdout.write('\r' + chalk.red(` ✗ Failed to download runner for "${name}": ${err?.message ?? String(err)}\n`));
962
+ continue;
963
+ }
964
+ }
817
965
  // Community plugins: download runner.js from the GitHub release first
818
966
  if (commDef) {
819
967
  process.stdout.write(` ↓ Downloading runner for ${chalk.bold(name)} …`);
@@ -837,6 +985,13 @@ pluginCmd
837
985
  console.log(chalk.green(` ✓ Installed`) + chalk.bold(` ${name}`) + chalk.gray(` — ${description}`));
838
986
  installedCount++;
839
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
+ }
840
995
  else {
841
996
  console.log(chalk.gray(` · Already installed`) + ` ${name}`);
842
997
  }
@@ -920,17 +1075,44 @@ pluginCmd
920
1075
  console.log(chalk.gray(` ${updatedCount} plugin(s) updated.`));
921
1076
  }
922
1077
  });
923
- // voiden-runner plugin uninstall <name>
1078
+ // voiden-runner plugin uninstall [names...] --all
924
1079
  pluginCmd
925
- .command('uninstall <name>')
926
- .description('Remove an installed plugin\n\n Example:\n voiden-runner plugin uninstall voiden-scripting\n')
927
- .action((name) => {
928
- const removed = uninstallPlugin(name);
929
- if (removed) {
930
- console.log(chalk.green(` ✓ Uninstalled`) + ` ${name}`);
1080
+ .command('uninstall [names...]')
1081
+ .description('Remove one or more installed plugins, or all installed plugins\n\n' +
1082
+ ' Examples:\n' +
1083
+ ' voiden-runner plugin uninstall voiden-scripting\n' +
1084
+ ' voiden-runner plugin uninstall apyhub-explorer voiden-scripting\n' +
1085
+ ' voiden-runner plugin uninstall --all\n')
1086
+ .option('--all', 'Uninstall all installed plugins (core and community)')
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;
1097
+ if (targets.length === 0) {
1098
+ console.error(chalk.red(' Specify plugin name(s) or use --all'));
1099
+ process.exit(EXIT_USAGE_ERROR);
1100
+ return;
931
1101
  }
932
- else {
933
- console.log(chalk.yellow(` ⚠ Plugin "${name}" is not installed`));
1102
+ let removedCount = 0;
1103
+ for (const name of targets) {
1104
+ const removed = uninstallPlugin(name, coreNames.has(name));
1105
+ if (removed) {
1106
+ console.log(chalk.green(` ✓ Uninstalled`) + ` ${name}`);
1107
+ removedCount++;
1108
+ }
1109
+ else {
1110
+ console.log(chalk.yellow(` ⚠ Plugin "${name}" is not installed`));
1111
+ }
1112
+ }
1113
+ if (removedCount > 1) {
1114
+ console.log();
1115
+ console.log(chalk.gray(` ${removedCount} plugin(s) uninstalled.`));
934
1116
  }
935
1117
  });
936
1118
  // voiden-runner plugin enable [name] --all
@@ -944,9 +1126,11 @@ pluginCmd
944
1126
  .action(async (name, opts) => {
945
1127
  if (opts.all) {
946
1128
  const store = readStore();
947
- // 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.
948
1132
  const disabled = Object.entries(store.installedPlugins)
949
- .filter(([, r]) => !r.enabled)
1133
+ .filter(([, r]) => !r.enabled && !r.uninstalled)
950
1134
  .map(([n]) => n);
951
1135
  // Also ensure all core plugins that were never in the store are treated as enabled (default)
952
1136
  const disabledCoreNotInStore = [];
@@ -963,14 +1147,14 @@ pluginCmd
963
1147
  }
964
1148
  if (!name) {
965
1149
  console.error(chalk.red(' Specify a plugin name or use --all'));
966
- process.exit(1);
1150
+ process.exit(EXIT_USAGE_ERROR);
967
1151
  }
968
1152
  const communityPlugins = await fetchCommunityPlugins();
969
1153
  const commDef = findCommunityPlugin(name, communityPlugins);
970
1154
  if (commDef && !hasCommunityRunner(name)) {
971
1155
  console.log(chalk.red(` ✗ Cannot enable "${name}" — runner not installed`));
972
1156
  console.log(chalk.gray(` Run: voiden-runner plugin install ${name}`));
973
- process.exit(1);
1157
+ process.exit(EXIT_USAGE_ERROR);
974
1158
  }
975
1159
  setPluginEnabled(name, true);
976
1160
  console.log(chalk.green(` ✓ Enabled`) + ` ${name}`);
@@ -1005,7 +1189,7 @@ pluginCmd
1005
1189
  }
1006
1190
  if (!name) {
1007
1191
  console.error(chalk.red(' Specify a plugin name or use --all'));
1008
- process.exit(1);
1192
+ process.exit(EXIT_USAGE_ERROR);
1009
1193
  return;
1010
1194
  }
1011
1195
  setPluginEnabled(name, false);
@@ -1035,10 +1219,16 @@ pluginCmd
1035
1219
  console.log(DIVIDER);
1036
1220
  for (const def of corePlugins) {
1037
1221
  const record = store.installedPlugins[def.name];
1038
- const isDisabled = record !== undefined && !record.enabled;
1039
- const statusBadge = isDisabled
1040
- ? chalk.yellow(' · disabled')
1041
- : 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
+ }
1042
1232
  console.log(` ${chalk.bold(def.name.padEnd(24))}${statusBadge}${updateBadge(def.name, def.version)}`);
1043
1233
  console.log(chalk.gray(` ${def.description}`));
1044
1234
  }
@@ -1091,5 +1281,87 @@ pluginCmd
1091
1281
  }
1092
1282
  console.log();
1093
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
+ });
1094
1366
  program.parse();
1095
1367
  //# sourceMappingURL=index.js.map