@scenerok/cli 2.4.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,651 @@
1
+ /**
2
+ * `scenerok plugin` — marketplace authoring commands (#122).
3
+ *
4
+ * init scaffold an RFC §4.1 package
5
+ * validate run the *server's* static checks locally (no network, no auth)
6
+ * publish validate, build the canonical tarball, upload, queue a build
7
+ * status show one build's stage, checks and resulting version
8
+ *
9
+ * `validate` and `publish` share `src/lib/plugins/cli-checks-entry.ts` with the
10
+ * server and the `plugin-builder` service, bundled into the CLI at build time
11
+ * (`scripts/build-cli-plugin-checks.mjs`). A local pass is therefore the same
12
+ * result the builder will produce — not an approximation.
13
+ *
14
+ * Exit codes: `0` success, `1` checks failed / build failed, `2` usage error.
15
+ */
16
+ import { Command } from 'commander';
17
+ import chalk from 'chalk';
18
+ import ora from 'ora';
19
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
20
+ import { dirname, join, resolve } from 'node:path';
21
+ import { getPlugin, getPluginBuild, runPlugin, searchPlugins, publishPluginPackage, } from '../lib/api.js';
22
+ import { getBaseUrl, isAuthenticated } from '../lib/config.js';
23
+ import { formatApiErrorMessage } from '../lib/format-api-error.js';
24
+ import { logError } from '../lib/logger.js';
25
+ import { checkPluginDirectory, formatBytes, loadPluginChecks, printStaticCheckReport, } from '../lib/plugin-checks.js';
26
+ import { buildScaffold } from '../lib/plugin-scaffold.js';
27
+ const EXIT_OK = 0;
28
+ const EXIT_FAILED = 1;
29
+ const EXIT_USAGE = 2;
30
+ function fail(write, message) {
31
+ write(chalk.red(`Error: ${message}`));
32
+ return EXIT_USAGE;
33
+ }
34
+ function plural(count, word) {
35
+ return `${count} ${word}${count === 1 ? '' : 's'}`;
36
+ }
37
+ function shortSha(sha) {
38
+ return `${sha.slice(0, 12)}…`;
39
+ }
40
+ /** `My Plugin` → `my-plugin`, for a helpful "did you mean" on a bad slug. */
41
+ export function slugify(name) {
42
+ return name
43
+ .toLowerCase()
44
+ .replace(/[^a-z0-9]+/g, '-')
45
+ .replace(/^-+|-+$/g, '')
46
+ .slice(0, 49);
47
+ }
48
+ export async function runPluginInit(options = {}) {
49
+ const write = options.write ?? ((line) => console.log(line));
50
+ const target = resolve(options.dir ?? '.');
51
+ const checks = await loadPluginChecks();
52
+ const kind = (options.kind ?? 'vertical').trim();
53
+ if (!checks.PLUGIN_KINDS.includes(kind)) {
54
+ return fail(write, `unknown --kind "${kind}". Choose one of: ${checks.PLUGIN_KINDS.join(', ')}`);
55
+ }
56
+ const rawNamespace = (options.namespace ?? '').trim().replace(/^@/, '');
57
+ if (!rawNamespace) {
58
+ return fail(write, 'missing --namespace. Use the handle you publish under, e.g. --namespace acme');
59
+ }
60
+ if (!/^[a-z][a-z0-9-]*$/.test(rawNamespace)) {
61
+ return fail(write, `invalid --namespace "${options.namespace}". Use lowercase letters, digits and dashes, starting with a letter.`);
62
+ }
63
+ const folderName = target.split(/[\\/]/).filter(Boolean).pop() ?? '';
64
+ const slug = (options.slug ?? folderName).trim();
65
+ if (!checks.SLUG_PATTERN.test(slug)) {
66
+ const suggestion = slugify(options.slug ?? folderName);
67
+ return fail(write, `invalid --slug "${slug}" (lowercase, 2–49 chars, starts with a letter).` +
68
+ (checks.SLUG_PATTERN.test(suggestion) ? ` Try: --slug ${suggestion}` : ''));
69
+ }
70
+ if (existsSync(target) && !statSync(target).isDirectory()) {
71
+ return fail(write, `${target} exists and is not a directory.`);
72
+ }
73
+ const existing = existsSync(target) ? readdirSync(target) : [];
74
+ if (existing.length > 0 && !options.force) {
75
+ return fail(write, `${target} is not empty (${plural(existing.length, 'entry')}: ${existing
76
+ .slice(0, 3)
77
+ .join(', ')}${existing.length > 3 ? ', …' : ''}).\n` +
78
+ 'Choose an empty directory or pass --force to add the missing scaffold files.');
79
+ }
80
+ const scaffold = buildScaffold({
81
+ kind,
82
+ slug,
83
+ namespace: rawNamespace,
84
+ title: options.title,
85
+ summary: options.summary,
86
+ });
87
+ const created = [];
88
+ const kept = [];
89
+ for (const [path, contents] of scaffold.files) {
90
+ const absolute = join(target, path);
91
+ if (existsSync(absolute)) {
92
+ kept.push(path);
93
+ continue;
94
+ }
95
+ mkdirSync(dirname(absolute), { recursive: true });
96
+ writeFileSync(absolute, contents, 'utf-8');
97
+ created.push(path);
98
+ }
99
+ const manifest = scaffold.manifest;
100
+ write('');
101
+ write(chalk.green(`✔ Created ${manifest.id}`) + chalk.dim(` (${manifest.kind})`));
102
+ write(chalk.dim(` ${target}`));
103
+ write('');
104
+ for (const path of created)
105
+ write(` ${chalk.green('+')} ${path}`);
106
+ for (const path of kept)
107
+ write(` ${chalk.dim('=')} ${path} ${chalk.dim('(kept)')}`);
108
+ write('');
109
+ write(chalk.cyan(' Next:'));
110
+ if (options.dir && resolve(options.dir) !== process.cwd()) {
111
+ write(` cd ${options.dir}`);
112
+ }
113
+ write(' scenerok plugin validate');
114
+ write(' scenerok plugin publish');
115
+ write('');
116
+ write(chalk.dim(' Manifest reference: docs/reference-plugin-manifest.md'));
117
+ write('');
118
+ return EXIT_OK;
119
+ }
120
+ export async function runPluginValidate(options = {}) {
121
+ const write = options.write ?? ((line) => console.log(line));
122
+ const dir = resolve(options.dir ?? '.');
123
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) {
124
+ return fail(write, `${dir} is not a directory.`);
125
+ }
126
+ const started = Date.now();
127
+ const { read, report } = await checkPluginDirectory(dir);
128
+ const elapsedMs = Date.now() - started;
129
+ const manifest = readManifestIdentity(read.files);
130
+ if (options.json) {
131
+ write(JSON.stringify({
132
+ ok: report.ok,
133
+ needsReview: report.needsReview,
134
+ directory: dir,
135
+ package: manifest,
136
+ files: read.files.size,
137
+ bytes: read.bytes,
138
+ skipped: read.skipped,
139
+ checks: report.checks,
140
+ }, null, 2));
141
+ return report.ok ? EXIT_OK : EXIT_FAILED;
142
+ }
143
+ const printOptions = {
144
+ title: manifest?.id ?? 'plugin package',
145
+ directory: dir,
146
+ files: read.files.size,
147
+ bytes: read.bytes,
148
+ verbose: options.verbose,
149
+ quiet: options.quiet,
150
+ write,
151
+ };
152
+ printStaticCheckReport(report, printOptions);
153
+ if (read.skipped.length > 0) {
154
+ write(chalk.dim(` Not packaged: ${read.skipped.slice(0, 6).join(', ')}${read.skipped.length > 6 ? ', …' : ''} (dotfiles and node_modules are never uploaded)`));
155
+ write('');
156
+ }
157
+ write(chalk.dim(` ${read.files.size} files · ${formatBytes(read.bytes)} · ${elapsedMs} ms · local checks`));
158
+ write('');
159
+ return report.ok ? EXIT_OK : EXIT_FAILED;
160
+ }
161
+ /** Best-effort identity from the manifest bytes (the checks already reported errors). */
162
+ function readManifestIdentity(files) {
163
+ const bytes = files.get('scenerok-plugin.json');
164
+ if (!bytes)
165
+ return null;
166
+ try {
167
+ const parsed = JSON.parse(new TextDecoder().decode(bytes));
168
+ return {
169
+ ...(typeof parsed.id === 'string' ? { id: parsed.id } : {}),
170
+ ...(typeof parsed.kind === 'string' ? { kind: parsed.kind } : {}),
171
+ ...(typeof parsed.title === 'string' ? { title: parsed.title } : {}),
172
+ ...(typeof parsed.versionLabel === 'string' ? { versionLabel: parsed.versionLabel } : {}),
173
+ };
174
+ }
175
+ catch {
176
+ return null;
177
+ }
178
+ }
179
+ const TERMINAL_BUILD_STATUSES = new Set(['passed', 'failed']);
180
+ export async function runPluginPublish(options = {}) {
181
+ const write = options.write ?? ((line) => console.log(line));
182
+ const dir = resolve(options.dir ?? '.');
183
+ if (!isAuthenticated()) {
184
+ write(chalk.yellow('Not authenticated'));
185
+ write(chalk.dim('Run: scenerok auth login'));
186
+ return EXIT_USAGE;
187
+ }
188
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) {
189
+ return fail(write, `${dir} is not a directory.`);
190
+ }
191
+ write('');
192
+ write(chalk.dim(` API: ${getBaseUrl()}`));
193
+ // 1. The same checks the builder will run — fail before uploading anything.
194
+ const validateStarted = Date.now();
195
+ const { read, report } = await checkPluginDirectory(dir);
196
+ const identity = readManifestIdentity(read.files);
197
+ if (!options.json) {
198
+ printStaticCheckReport(report, {
199
+ title: identity?.id ?? 'plugin package',
200
+ directory: dir,
201
+ files: read.files.size,
202
+ bytes: read.bytes,
203
+ write,
204
+ });
205
+ }
206
+ if (!report.ok) {
207
+ logError('plugin publish blocked by local checks', { dir });
208
+ write(chalk.red(' Nothing was uploaded. Fix the errors above and run publish again.'));
209
+ write('');
210
+ return EXIT_FAILED;
211
+ }
212
+ write(chalk.green(` ✔ Local checks passed`) +
213
+ chalk.dim(` (${Date.now() - validateStarted} ms · ${read.files.size} files · ${formatBytes(read.bytes)})`));
214
+ // 2. Canonical tarball + content hash (the server recomputes it).
215
+ const checks = await loadPluginChecks();
216
+ const tarball = checks.buildCanonicalTarGz(read.files);
217
+ const sha256 = checks.computeSourceSha256(read.files);
218
+ write(chalk.dim(` Tarball ${formatBytes(tarball.byteLength)} · sha256 ${shortSha(sha256)}`));
219
+ const spinner = options.json ? null : ora('Uploading…').start();
220
+ try {
221
+ const form = new FormData();
222
+ form.set('tarball', new Blob([new Uint8Array(tarball)], { type: 'application/gzip' }), 'plugin.tgz');
223
+ form.set('sourceSha256', sha256);
224
+ const result = await publishPluginPackage(form);
225
+ spinner?.succeed('Uploaded');
226
+ if (options.json) {
227
+ write(JSON.stringify(result, null, 2));
228
+ }
229
+ else {
230
+ write('');
231
+ write(chalk.green(` ✔ Build ${result.buildId}`) + (result.created ? '' : chalk.dim(' (reused — same source)')));
232
+ write(` ${chalk.bold(result.package.id)}` +
233
+ chalk.dim(` · ${result.package.kind}${result.package.versionLabel ? ` · v${result.package.versionLabel}` : ''}`));
234
+ write('');
235
+ write(chalk.cyan(' Next:'));
236
+ write(` scenerok plugin status ${result.buildId}`);
237
+ write(` scenerok plugin status ${result.buildId} --watch`);
238
+ write('');
239
+ write(chalk.dim(' The build runs the same checks you just ran, then creates the version for review.'));
240
+ write('');
241
+ }
242
+ if (options.watch) {
243
+ return await watchPluginBuild(result.buildId, {
244
+ write,
245
+ json: options.json,
246
+ verbose: false,
247
+ pollIntervalMs: options.pollIntervalMs,
248
+ watchTimeoutMs: options.watchTimeoutMs,
249
+ });
250
+ }
251
+ return EXIT_OK;
252
+ }
253
+ catch (error) {
254
+ spinner?.fail('Upload failed');
255
+ logError('plugin publish failed', { dir, error: formatApiErrorMessage(error) });
256
+ write(chalk.red(` ${formatApiErrorMessage(error)}`));
257
+ write('');
258
+ return error && typeof error === 'object' && 'status' in error && error.status === 401
259
+ ? EXIT_USAGE
260
+ : EXIT_FAILED;
261
+ }
262
+ }
263
+ function printBuild(view, write, verbose) {
264
+ const tone = view.status === 'passed' ? chalk.green : view.status === 'failed' ? chalk.red : chalk.cyan;
265
+ write('');
266
+ write(` Build ${chalk.bold(view.id)}`);
267
+ write(` Status ${tone(view.status)}${view.stage !== view.status ? chalk.dim(` · stage ${view.stage}`) : ''}`);
268
+ if (view.plugin) {
269
+ write(` Package ${chalk.bold(`@${view.plugin.namespace}/${view.plugin.slug}`)}`);
270
+ }
271
+ if (view.version) {
272
+ const label = view.version.versionLabel ? chalk.dim(` · ${view.version.versionLabel}`) : '';
273
+ write(` Version ${view.version.version} ${chalk.dim(`(${view.version.status})`)}${label}`);
274
+ }
275
+ if (view.needsReview !== null) {
276
+ write(` Review ${view.needsReview ? chalk.yellow('human review required') : chalk.dim('no extra review')}`);
277
+ }
278
+ write(` Source ${chalk.dim(shortSha(view.sourceSha256))}`);
279
+ write(` Created ${chalk.dim(view.createdAt)}`);
280
+ if (view.reason) {
281
+ write('');
282
+ write(chalk.red(` Reason: ${view.reason}`));
283
+ }
284
+ if (view.checks.length > 0) {
285
+ printStaticCheckReport({
286
+ ok: view.status !== 'failed',
287
+ needsReview: view.needsReview ?? false,
288
+ checks: view.checks,
289
+ }, { verbose, quiet: false, write });
290
+ }
291
+ else {
292
+ write('');
293
+ write(chalk.dim(` ${view.status === 'queued' ? 'Queued — checks have not run yet.' : 'No check report yet.'}`));
294
+ write('');
295
+ }
296
+ if (verbose && view.log) {
297
+ write(chalk.dim(' Build log:'));
298
+ for (const line of view.log.split('\n'))
299
+ write(chalk.dim(` ${line}`));
300
+ write('');
301
+ }
302
+ }
303
+ async function watchPluginBuild(buildId, options) {
304
+ const { write } = options;
305
+ const interval = options.pollIntervalMs ?? 2000;
306
+ const timeout = options.watchTimeoutMs ?? 10 * 60 * 1000;
307
+ const started = Date.now();
308
+ let lastStage = '';
309
+ if (!options.json) {
310
+ write(chalk.dim(` Watching ${buildId} (Ctrl-C to stop)…`));
311
+ }
312
+ while (true) {
313
+ const view = await getPluginBuild(buildId);
314
+ if (view.stage !== lastStage && !options.json) {
315
+ lastStage = view.stage;
316
+ write(chalk.dim(` · ${view.status} (${view.stage})`));
317
+ }
318
+ if (TERMINAL_BUILD_STATUSES.has(view.status)) {
319
+ if (options.json)
320
+ write(JSON.stringify(view, null, 2));
321
+ else
322
+ printBuild(view, write, options.verbose);
323
+ if (view.status === 'failed') {
324
+ write(chalk.red(' Build failed — nothing was published. Fix the errors and re-run publish.'));
325
+ write('');
326
+ return EXIT_FAILED;
327
+ }
328
+ write(chalk.green(` Build passed — version ${view.version?.version ?? '?'} is awaiting review.`));
329
+ write('');
330
+ return EXIT_OK;
331
+ }
332
+ if (Date.now() - started > timeout) {
333
+ write(chalk.yellow(` Still ${view.status} after ${Math.round(timeout / 60000)} min. Check again with: scenerok plugin status ${buildId}`));
334
+ write('');
335
+ return EXIT_OK;
336
+ }
337
+ await new Promise((resolveWait) => setTimeout(resolveWait, interval));
338
+ }
339
+ }
340
+ export async function runPluginStatus(options) {
341
+ const write = options.write ?? ((line) => console.log(line));
342
+ if (!isAuthenticated()) {
343
+ write(chalk.yellow('Not authenticated'));
344
+ write(chalk.dim('Run: scenerok auth login'));
345
+ return EXIT_USAGE;
346
+ }
347
+ if (!options.buildId || options.buildId.trim().length === 0) {
348
+ return fail(write, 'missing build id. Usage: scenerok plugin status <buildId>');
349
+ }
350
+ try {
351
+ if (options.watch) {
352
+ return await watchPluginBuild(options.buildId, {
353
+ write,
354
+ json: options.json,
355
+ verbose: options.verbose === true,
356
+ pollIntervalMs: options.pollIntervalMs,
357
+ watchTimeoutMs: options.watchTimeoutMs,
358
+ });
359
+ }
360
+ const view = await getPluginBuild(options.buildId);
361
+ if (options.json) {
362
+ write(JSON.stringify(view, null, 2));
363
+ }
364
+ else {
365
+ printBuild(view, write, options.verbose === true);
366
+ }
367
+ return view.status === 'failed' ? EXIT_FAILED : EXIT_OK;
368
+ }
369
+ catch (error) {
370
+ write(chalk.red(` ${formatApiErrorMessage(error)}`));
371
+ write('');
372
+ return EXIT_FAILED;
373
+ }
374
+ }
375
+ function parsePluginLookupRef(raw) {
376
+ let value = raw.trim();
377
+ if (value.startsWith('scenerok://plugin/'))
378
+ value = value.slice('scenerok://plugin/'.length);
379
+ if (value.startsWith('@')) {
380
+ const slash = value.indexOf('/');
381
+ if (slash > 0)
382
+ value = value.slice(slash + 1);
383
+ }
384
+ const at = value.lastIndexOf('@');
385
+ const slug = at > 0 ? value.slice(0, at) : value;
386
+ const version = at > 0 ? value.slice(at + 1) : undefined;
387
+ if (!/^[a-z][a-z0-9-]{1,48}$/.test(slug)) {
388
+ throw new Error(`invalid plugin ref "${raw}"`);
389
+ }
390
+ return { slug, version };
391
+ }
392
+ function parseJsonValue(raw) {
393
+ const trimmed = raw.trim();
394
+ if (trimmed === '')
395
+ return '';
396
+ try {
397
+ return JSON.parse(trimmed);
398
+ }
399
+ catch {
400
+ return raw;
401
+ }
402
+ }
403
+ function assignPath(target, path, value) {
404
+ const parts = path.split('.').filter(Boolean);
405
+ if (parts.length === 0)
406
+ throw new Error('empty --set key');
407
+ let cursor = target;
408
+ for (const part of parts.slice(0, -1)) {
409
+ const existing = cursor[part];
410
+ if (!existing || typeof existing !== 'object' || Array.isArray(existing)) {
411
+ cursor[part] = {};
412
+ }
413
+ cursor = cursor[part];
414
+ }
415
+ cursor[parts[parts.length - 1]] = value;
416
+ }
417
+ function parseSetOverride(raw) {
418
+ const equals = raw.indexOf('=');
419
+ if (equals <= 0)
420
+ throw new Error(`invalid --set "${raw}" (expected key=value)`);
421
+ return {
422
+ key: raw.slice(0, equals).trim(),
423
+ value: parseJsonValue(raw.slice(equals + 1)),
424
+ };
425
+ }
426
+ function loadRunInputs(options) {
427
+ let inputs = {};
428
+ if (options.input) {
429
+ const parsed = JSON.parse(readFileSync(resolve(options.input), 'utf-8'));
430
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
431
+ throw new Error('--input must point to a JSON object');
432
+ }
433
+ inputs = { ...parsed };
434
+ }
435
+ for (const raw of options.set ?? []) {
436
+ const override = parseSetOverride(raw);
437
+ assignPath(inputs, override.key, override.value);
438
+ }
439
+ return inputs;
440
+ }
441
+ function parseMaxTokens(value) {
442
+ const parsed = typeof value === 'number' ? value : Number.parseInt(String(value ?? ''), 10);
443
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) {
444
+ throw new Error('missing or invalid --max-tokens N (positive integer required)');
445
+ }
446
+ return parsed;
447
+ }
448
+ function costLine(estimate, worstCase) {
449
+ if (!estimate)
450
+ return `worst case ${worstCase.totalTokens} tokens`;
451
+ return `estimate ${estimate.totalTokens} tokens · worst case ${worstCase.totalTokens} tokens`;
452
+ }
453
+ function printPluginSummary(plugin, write) {
454
+ const label = plugin.latestVersion.versionLabel ? ` (${plugin.latestVersion.versionLabel})` : '';
455
+ write(`${chalk.bold(plugin.title)} ${chalk.dim(plugin.ref)}${label}`);
456
+ if (plugin.summary)
457
+ write(` ${plugin.summary}`);
458
+ write(` ${plugin.kind}${plugin.category ? ` · ${plugin.category}` : ''} · ${costLine(plugin.typicalCost?.estimate ?? null, plugin.worstCase)}`);
459
+ const permissions = [
460
+ plugin.permissions.providers.length ? `providers: ${plugin.permissions.providers.join(', ')}` : null,
461
+ plugin.permissions.connectors.length ? `connectors: ${plugin.permissions.connectors.join(', ')}` : null,
462
+ plugin.permissions.network.length ? `network: ${plugin.permissions.network.join(', ')}` : null,
463
+ ].filter(Boolean);
464
+ if (permissions.length)
465
+ write(chalk.dim(` ${permissions.join(' · ')}`));
466
+ if (plugin.badges.length)
467
+ write(chalk.dim(` badges: ${plugin.badges.join(', ')}`));
468
+ }
469
+ function printPluginDetail(plugin, write) {
470
+ printPluginSummary(plugin, write);
471
+ write('');
472
+ write(chalk.cyan('Input schema:'));
473
+ write(JSON.stringify(plugin.inputSchema, null, 2));
474
+ if (plugin.typicalCost) {
475
+ write('');
476
+ write(chalk.cyan(`Typical cost (${plugin.typicalCost.examplePath}):`));
477
+ write(JSON.stringify(plugin.typicalCost.estimate, null, 2));
478
+ }
479
+ if (plugin.readmeExcerpt) {
480
+ write('');
481
+ write(chalk.cyan('README excerpt:'));
482
+ write(plugin.readmeExcerpt);
483
+ }
484
+ if (plugin.untrusted_text_notice) {
485
+ write('');
486
+ write(chalk.yellow(plugin.untrusted_text_notice));
487
+ }
488
+ }
489
+ function printPluginRunResult(result, write) {
490
+ write(`${chalk.green('Estimate')} ${result.estimate.totalTokens} tokens · ${chalk.yellow('worst case')} ${result.worstCaseTokens} tokens`);
491
+ if (result.dryRun) {
492
+ write(chalk.dim('Dry run only; no render was created.'));
493
+ return;
494
+ }
495
+ if (result.render) {
496
+ write(`${chalk.green('Render')} ${result.render.renderId} · ${result.render.status}`);
497
+ if (result.render.renderUrl)
498
+ write(`Page: ${result.render.renderUrl}`);
499
+ if (result.render.downloadUrl)
500
+ write(`Download: ${result.render.downloadUrl}`);
501
+ write(chalk.dim(`Next: scenerok status ${result.render.renderId}`));
502
+ }
503
+ }
504
+ export async function runPluginSearch(options = {}) {
505
+ const write = options.write ?? ((line) => console.log(line));
506
+ try {
507
+ const result = await searchPlugins({
508
+ q: options.q,
509
+ category: options.category,
510
+ kind: options.kind,
511
+ limit: options.limit,
512
+ });
513
+ if (options.json) {
514
+ write(JSON.stringify(result, null, 2));
515
+ return EXIT_OK;
516
+ }
517
+ if (result.plugins.length === 0) {
518
+ write('No plugins found.');
519
+ return EXIT_OK;
520
+ }
521
+ for (const plugin of result.plugins) {
522
+ printPluginSummary(plugin, write);
523
+ write('');
524
+ }
525
+ return EXIT_OK;
526
+ }
527
+ catch (error) {
528
+ write(chalk.red(` ${formatApiErrorMessage(error)}`));
529
+ write('');
530
+ return EXIT_FAILED;
531
+ }
532
+ }
533
+ export async function runPluginShow(options) {
534
+ const write = options.write ?? ((line) => console.log(line));
535
+ try {
536
+ const ref = parsePluginLookupRef(options.ref);
537
+ const result = await getPlugin(ref.slug, ref.version);
538
+ if (options.json)
539
+ write(JSON.stringify(result.plugin, null, 2));
540
+ else
541
+ printPluginDetail(result.plugin, write);
542
+ return EXIT_OK;
543
+ }
544
+ catch (error) {
545
+ write(chalk.red(` ${formatApiErrorMessage(error)}`));
546
+ write('');
547
+ return EXIT_FAILED;
548
+ }
549
+ }
550
+ export async function runPluginRunCommand(options) {
551
+ const write = options.write ?? ((line) => console.log(line));
552
+ if (!isAuthenticated()) {
553
+ write(chalk.yellow('Not authenticated'));
554
+ write(chalk.dim('Run: scenerok auth login'));
555
+ return EXIT_USAGE;
556
+ }
557
+ try {
558
+ const inputs = loadRunInputs(options);
559
+ const maxTokens = parseMaxTokens(options.maxTokens);
560
+ const result = await runPlugin({
561
+ ref: options.ref,
562
+ inputs,
563
+ maxTokens,
564
+ dryRun: options.dryRun,
565
+ });
566
+ if (options.json)
567
+ write(JSON.stringify(result, null, 2));
568
+ else
569
+ printPluginRunResult(result, write);
570
+ return EXIT_OK;
571
+ }
572
+ catch (error) {
573
+ write(chalk.red(` ${formatApiErrorMessage(error)}`));
574
+ write('');
575
+ return EXIT_FAILED;
576
+ }
577
+ }
578
+ // ---------------------------------------------------------------------------
579
+ // command wiring
580
+ // ---------------------------------------------------------------------------
581
+ export const pluginCommand = new Command('plugin')
582
+ .description('Create, validate, publish and run marketplace plugins')
583
+ .addCommand(new Command('init')
584
+ .description('Scaffold a new plugin package')
585
+ .argument('[dir]', 'Directory to create the package in', '.')
586
+ .option('-k, --kind <kind>', 'Plugin kind (vertical, template-pack, code, …)', 'vertical')
587
+ .option('-s, --slug <slug>', 'Plugin slug (defaults to the directory name)')
588
+ .option('-n, --namespace <namespace>', 'Your publisher namespace, e.g. acme')
589
+ .option('-t, --title <title>', 'Human-readable title')
590
+ .option('--summary <summary>', 'One-line listing summary')
591
+ .option('--force', 'Add missing scaffold files to a non-empty directory')
592
+ .action(async (dir, options) => {
593
+ process.exitCode = await runPluginInit({ ...options, dir });
594
+ }))
595
+ .addCommand(new Command('validate')
596
+ .description('Run the publish checks locally (no network, no credentials)')
597
+ .argument('[dir]', 'Plugin package directory', '.')
598
+ .option('--json', 'Machine-readable report')
599
+ .option('--verbose', 'List every passing check')
600
+ .option('--quiet', 'Only show failures')
601
+ .action(async (dir, options) => {
602
+ process.exitCode = await runPluginValidate({ ...options, dir });
603
+ }))
604
+ .addCommand(new Command('publish')
605
+ .description('Validate, upload and queue a build for review')
606
+ .argument('[dir]', 'Plugin package directory', '.')
607
+ .option('--watch', 'Follow the build until it passes or fails')
608
+ .option('--json', 'Machine-readable output')
609
+ .action(async (dir, options) => {
610
+ process.exitCode = await runPluginPublish({ ...options, dir });
611
+ }))
612
+ .addCommand(new Command('status')
613
+ .description('Show a build’s stage, checks and resulting version')
614
+ .argument('<buildId>', 'Build id printed by `scenerok plugin publish`')
615
+ .option('--watch', 'Follow the build until it passes or fails')
616
+ .option('--json', 'Machine-readable output')
617
+ .option('-v, --verbose', 'Include the timestamped build log')
618
+ .action(async (buildId, options) => {
619
+ process.exitCode = await runPluginStatus({ ...options, buildId });
620
+ }))
621
+ .addCommand(new Command('search')
622
+ .description('Search published marketplace plugins')
623
+ .argument('[q]', 'Search text')
624
+ .option('--category <category>', 'Filter by category')
625
+ .option('--kind <kind>', 'Filter by manifest kind')
626
+ .option('--limit <n>', 'Maximum results', (value) => Number.parseInt(value, 10))
627
+ .option('--json', 'Machine-readable output')
628
+ .action(async (q, options) => {
629
+ process.exitCode = await runPluginSearch({ ...options, q });
630
+ }))
631
+ .addCommand(new Command('show')
632
+ .description('Show a published plugin schema, costs and permissions')
633
+ .argument('<ref>', 'Plugin slug or ref')
634
+ .option('--json', 'Machine-readable output')
635
+ .action(async (ref, options) => {
636
+ process.exitCode = await runPluginShow({ ...options, ref });
637
+ }))
638
+ .addCommand(new Command('run')
639
+ .description('Run a published plugin')
640
+ .argument('<ref>', 'Pinned plugin ref')
641
+ .option('--input <file>', 'JSON input object')
642
+ .option('--set <key=value>', 'Set or override an input value (repeatable)', (value, previous = []) => [
643
+ ...previous,
644
+ value,
645
+ ])
646
+ .requiredOption('--max-tokens <n>', 'Hard token cap; refused before debit if worst-case is higher')
647
+ .option('--dry-run', 'Return estimate and bound without creating a render')
648
+ .option('--json', 'Machine-readable output')
649
+ .action(async (ref, options) => {
650
+ process.exitCode = await runPluginRunCommand({ ...options, ref });
651
+ }));
package/dist/index.js CHANGED
@@ -11,6 +11,7 @@ import { validateCommand } from './commands/validate.js';
11
11
  import { lintVideoCommand } from './commands/lint-video.js';
12
12
  import { projectCommand } from './commands/project.js';
13
13
  import { cacheCommand } from './commands/cache.js';
14
+ import { pluginCommand } from './commands/plugin.js';
14
15
  const program = new Command();
15
16
  const packageJson = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf-8'));
16
17
  program
@@ -23,6 +24,7 @@ program.addCommand(skillsCommand);
23
24
  program.addCommand(renderCommand);
24
25
  program.addCommand(projectCommand);
25
26
  program.addCommand(cacheCommand);
27
+ program.addCommand(pluginCommand);
26
28
  program.addCommand(validateCommand);
27
29
  program.addCommand(lintVideoCommand);
28
30
  program.addCommand(statusCommand);
@@ -43,6 +45,11 @@ program.on('--help', () => {
43
45
  console.log(' $ scenerok lint-video 42 --cost low --json');
44
46
  console.log(' $ scenerok project upload video.vidscript --assets ./assets --render --watch --download');
45
47
  console.log(' $ scenerok cache pull');
48
+ console.log(' $ scenerok plugin init my-plugin --namespace acme --kind vertical');
49
+ console.log(' $ scenerok plugin validate');
50
+ console.log(' $ scenerok plugin publish --watch');
51
+ console.log(' $ scenerok plugin search "product promo"');
52
+ console.log(' $ scenerok plugin run scenerok://plugin/listing-promo@1 --input run.json --max-tokens 1000');
46
53
  console.log(' $ scenerok status 42');
47
54
  console.log('');
48
55
  console.log(chalk.cyan('Get started:'));