@mrgoonie/multix 0.14.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.15.0](https://github.com/mrgoonie/multix-cli/compare/v0.14.1...v0.15.0) (2026-10-11)
9
+
10
+
11
+ ### Added
12
+
13
+ * **mcp:** add Streamable HTTP MCP server for Cloudflare Workers ([#91](https://github.com/mrgoonie/multix-cli/issues/91)) ([45a662c](https://github.com/mrgoonie/multix-cli/commit/45a662c638d9211009e429bc129edc913a010729))
14
+
8
15
  ## [0.14.1](https://github.com/mrgoonie/multix-cli/compare/v0.14.0...v0.14.1) (2026-10-11)
9
16
 
10
17
 
package/README.md CHANGED
@@ -674,6 +674,37 @@ Thumbnail detection is automatic: if the provider response includes any of `cove
674
674
 
675
675
  Cloudflare video is intentionally separate: it has no thumbnail flags, and `generate-video --wait` already saves the completed MP4. See [`multix cloudflare`](#multix-cloudflare) for its exact polling and download behavior.
676
676
 
677
+ ## Remote MCP server (Cloudflare Workers)
678
+
679
+ `worker/` contains an optional, stateless MCP server that exposes remote-safe
680
+ image and video tools over Streamable HTTP at `/mcp`, for agents that cannot
681
+ run the CLI locally. It is a separate package and is not included in the npm
682
+ CLI.
683
+
684
+ - Tools: `multix_list_providers`, `multix_generate_image` (Replicate, Atlas
685
+ Cloud), `multix_generate_video` (Replicate, Atlas Cloud, HeyGen, Cloudflare
686
+ AI Gateway; returns an ID without blocking), `multix_prediction_status`,
687
+ `multix_prediction_cancel` (Replicate). Results are provider URLs, not bytes.
688
+ - Auth: `Authorization: Bearer <MCP_AUTH_TOKEN>` (at least 32 characters; the
689
+ server returns `503` if it is unset). `/health` is public.
690
+ - Single-tenant: provider keys are Worker secrets, and every caller spends the
691
+ deployer's credits. Limit exposure with `MULTIX_MCP_ALLOWED_PROVIDERS`,
692
+ `MULTIX_MCP_ALLOWED_MODELS`, `MULTIX_MCP_MAX_OUTPUTS`,
693
+ `MULTIX_MCP_MAX_VIDEO_SECONDS` (also sent as `min(5, cap)` when a caller
694
+ omits `duration`), `MULTIX_MCP_ALLOWED_RESOLUTIONS` (default
695
+ `480p,720p,1080p`), `MULTIX_MCP_ALLOWED_ORIGINS` (browser origins, with
696
+ CORS), and the approximate per-IP rate limiter. JSON-RPC batches are
697
+ rejected.
698
+
699
+ ```bash
700
+ npm ci --prefix worker
701
+ cd worker && openssl rand -hex 32 | npx wrangler secret put MCP_AUTH_TOKEN
702
+ npx wrangler secret put REPLICATE_API_TOKEN # and any other provider keys
703
+ cd .. && npm run deploy:mcp # or: npm run dev:mcp (local, port 8787)
704
+ ```
705
+
706
+ Full guide: [Remote MCP server](https://multix.zuey.me/guides/mcp-server/).
707
+
677
708
  ## Troubleshooting
678
709
 
679
710
  **No API key found:** Run `multix check` — it prints setup instructions.
package/dist/cli.js CHANGED
@@ -50,9 +50,10 @@ var init_errors = __esm({
50
50
  super(message);
51
51
  this.code = code;
52
52
  this.name = "MultixError";
53
- if (Error.captureStackTrace) {
54
- Error.captureStackTrace(this, this.constructor);
55
- }
53
+ Error.captureStackTrace?.(
54
+ this,
55
+ this.constructor
56
+ );
56
57
  }
57
58
  code;
58
59
  };
@@ -640,8 +641,8 @@ function predictionErrorMessage(error) {
640
641
  }
641
642
  return "";
642
643
  }
643
- function collectOutputs(prediction2) {
644
- const raw = prediction2.outputs ?? prediction2.output;
644
+ function collectOutputs(prediction) {
645
+ const raw = prediction.outputs ?? prediction.output;
645
646
  const list = Array.isArray(raw) ? raw : raw ? [raw] : [];
646
647
  const out = [];
647
648
  for (const item of list) {
@@ -751,16 +752,16 @@ function defaultExtensionFor(category) {
751
752
  return "bin";
752
753
  }
753
754
  }
754
- function classifyOutputs(prediction2) {
755
- const outputs = collectOutputs(prediction2);
755
+ function classifyOutputs(prediction) {
756
+ const outputs = collectOutputs(prediction);
756
757
  if (outputs.length === 0) return { mode: "empty" };
757
758
  const urls = outputs.filter(isMediaOutput);
758
759
  const texts = outputs.filter((o) => !isMediaOutput(o));
759
760
  if (urls.length === 0) return { mode: "text", texts };
760
761
  return { mode: "media", urls, texts };
761
762
  }
762
- function hasNsfwFlag(prediction2) {
763
- return Array.isArray(prediction2.has_nsfw_contents) && prediction2.has_nsfw_contents.some(Boolean);
763
+ function hasNsfwFlag(prediction) {
764
+ return Array.isArray(prediction.has_nsfw_contents) && prediction.has_nsfw_contents.some(Boolean);
764
765
  }
765
766
  function outputFileName(predictionId, index, ext) {
766
767
  const prefix = predictionId.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 8) || "output";
@@ -773,10 +774,10 @@ var DEFAULT_UPLOAD_TIMEOUT_MS = 3e5;
773
774
  var CATALOG_TIMEOUT_MS = 15e3;
774
775
  var PREDICTION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
775
776
  var AtlasPredictionFailedError = class extends ProviderError {
776
- constructor(prediction2) {
777
- const reason = predictionErrorMessage(prediction2.error) || `status "${prediction2.status}"`;
778
- super(`Atlas Cloud prediction ${prediction2.id} failed: ${reason}`, ATLAS_PROVIDER);
779
- this.prediction = prediction2;
777
+ constructor(prediction) {
778
+ const reason = predictionErrorMessage(prediction.error) || `status "${prediction.status}"`;
779
+ super(`Atlas Cloud prediction ${prediction.id} failed: ${reason}`, ATLAS_PROVIDER);
780
+ this.prediction = prediction;
780
781
  this.name = "AtlasPredictionFailedError";
781
782
  }
782
783
  prediction;
@@ -786,11 +787,11 @@ function assertValidPredictionId(id) {
786
787
  throw new ValidationError(`Invalid Atlas Cloud prediction id: "${id}"`);
787
788
  }
788
789
  }
789
- function assertPredictionSucceeded(prediction2) {
790
- if (isFailureStatus(prediction2.status)) throw new AtlasPredictionFailedError(prediction2);
791
- if (!isSuccessStatus(prediction2.status)) {
790
+ function assertPredictionSucceeded(prediction) {
791
+ if (isFailureStatus(prediction.status)) throw new AtlasPredictionFailedError(prediction);
792
+ if (!isSuccessStatus(prediction.status)) {
792
793
  throw new ProviderError(
793
- `Atlas Cloud prediction ${prediction2.id} is not finished (status "${prediction2.status}")`,
794
+ `Atlas Cloud prediction ${prediction.id} is not finished (status "${prediction.status}")`,
794
795
  ATLAS_PROVIDER
795
796
  );
796
797
  }
@@ -1694,22 +1695,22 @@ function planOutputPaths(urls, cfg) {
1694
1695
  const base = abs.slice(0, -path7.extname(abs).length);
1695
1696
  return exts.map((ext, i) => `${base}-${String(i + 1).padStart(2, "0")}.${ext}`);
1696
1697
  }
1697
- async function deliverPrediction(prediction2, cfg) {
1698
+ async function deliverPrediction(prediction, cfg) {
1698
1699
  const { logger, opts } = cfg;
1699
- if (prediction2.price !== void 0 && prediction2.price !== "") {
1700
- logger.info(`Cost: $${prediction2.price}`);
1700
+ if (prediction.price !== void 0 && prediction.price !== "") {
1701
+ logger.info(`Cost: $${prediction.price}`);
1701
1702
  }
1702
- const classified = classifyOutputs(prediction2);
1703
+ const classified = classifyOutputs(prediction);
1703
1704
  if (classified.mode === "empty") {
1704
1705
  throw new ProviderError(
1705
- hasNsfwFlag(prediction2) ? `Prediction ${prediction2.id} returned no output: blocked by content safety (has_nsfw_contents). Try a different prompt or input.` : `Prediction ${prediction2.id} completed but returned no outputs.`,
1706
+ hasNsfwFlag(prediction) ? `Prediction ${prediction.id} returned no output: blocked by content safety (has_nsfw_contents). Try a different prompt or input.` : `Prediction ${prediction.id} completed but returned no outputs.`,
1706
1707
  ATLAS_PROVIDER
1707
1708
  );
1708
1709
  }
1709
1710
  for (const text of classified.texts) console.log(text);
1710
1711
  if (classified.mode === "text") {
1711
1712
  if (opts.output) {
1712
- const dest = isDirectoryOutput(opts.output) ? path7.resolve(opts.output, outputFileName(prediction2.id, 0, "txt")) : path7.resolve(opts.output);
1713
+ const dest = isDirectoryOutput(opts.output) ? path7.resolve(opts.output, outputFileName(prediction.id, 0, "txt")) : path7.resolve(opts.output);
1713
1714
  fs8.mkdirSync(path7.dirname(dest), { recursive: true });
1714
1715
  fs8.writeFileSync(dest, `${classified.texts.join("\n\n")}
1715
1716
  `, "utf8");
@@ -1723,7 +1724,7 @@ async function deliverPrediction(prediction2, cfg) {
1723
1724
  return { files: [], texts: classified.texts };
1724
1725
  }
1725
1726
  const dests = planOutputPaths(classified.urls, {
1726
- predictionId: prediction2.id,
1727
+ predictionId: prediction.id,
1727
1728
  outDir: getOutputDir,
1728
1729
  output: opts.output,
1729
1730
  hint: cfg.hint
@@ -1751,7 +1752,7 @@ async function deliverPrediction(prediction2, cfg) {
1751
1752
  }
1752
1753
  if (saved.length === 0) {
1753
1754
  throw new ProviderError(
1754
- `Prediction ${prediction2.id} produced ${classified.urls.length} output(s) but none downloaded. Retry with: ${resumeCommand(prediction2.id)}`,
1755
+ `Prediction ${prediction.id} produced ${classified.urls.length} output(s) but none downloaded. Retry with: ${resumeCommand(prediction.id)}`,
1755
1756
  ATLAS_PROVIDER
1756
1757
  );
1757
1758
  }
@@ -1766,14 +1767,14 @@ async function deliverPrediction(prediction2, cfg) {
1766
1767
  const files = saved.map((f) => finalImages[images.indexOf(f)] ?? f);
1767
1768
  const firstVideo = files.find((f) => outputCategory(f) === "video");
1768
1769
  if (firstVideo) {
1769
- await maybeDownloadThumb(prediction2, firstVideo, { skip: opts.thumb === false, logger });
1770
+ await maybeDownloadThumb(prediction, firstVideo, { skip: opts.thumb === false, logger });
1770
1771
  }
1771
1772
  console.log(`
1772
- Prediction ${prediction2.id}: saved ${files.length} file(s)`);
1773
+ Prediction ${prediction.id}: saved ${files.length} file(s)`);
1773
1774
  for (const f of files) console.log(` ${f}`);
1774
1775
  if (failed > 0) {
1775
1776
  throw new ProviderError(
1776
- `Prediction ${prediction2.id}: downloaded ${saved.length} of ${classified.urls.length} output(s); ${failed} failed. Saved: ${files.join(", ")}. Retry with: ${resumeCommand(prediction2.id)}`,
1777
+ `Prediction ${prediction.id}: downloaded ${saved.length} of ${classified.urls.length} output(s); ${failed} failed. Saved: ${files.join(", ")}. Retry with: ${resumeCommand(prediction.id)}`,
1777
1778
  ATLAS_PROVIDER
1778
1779
  );
1779
1780
  }
@@ -2005,18 +2006,18 @@ function registerAtlasStatusCommand(parent) {
2005
2006
  const logger = createLogger({ verbose: opts.verbose ?? false });
2006
2007
  const client = createAtlasClientFromEnv();
2007
2008
  const wait = Boolean(opts.wait || opts.download);
2008
- const prediction2 = wait ? await client.waitForPrediction(predictionId, {
2009
+ const prediction = wait ? await client.waitForPrediction(predictionId, {
2009
2010
  timeoutMs: parseTimeoutMs(opts.waitTimeout, STATUS_WAIT_MS),
2010
2011
  resumeHint: `Resume with: ${resumeCommand(predictionId)}`,
2011
2012
  onPoll: (p, n) => logger.debug(`poll ${n}: ${p.status}`)
2012
2013
  }) : await client.getPrediction(predictionId);
2013
2014
  if (!opts.download) {
2014
- console.log(JSON.stringify(prediction2, null, 2));
2015
- if (isFailureStatus(prediction2.status)) throw new AtlasPredictionFailedError(prediction2);
2015
+ console.log(JSON.stringify(prediction, null, 2));
2016
+ if (isFailureStatus(prediction.status)) throw new AtlasPredictionFailedError(prediction);
2016
2017
  return;
2017
2018
  }
2018
- assertPredictionSucceeded(prediction2);
2019
- await deliverPrediction(prediction2, { logger, opts: { ...opts, download: true } });
2019
+ assertPredictionSucceeded(prediction);
2020
+ await deliverPrediction(prediction, { logger, opts: { ...opts, download: true } });
2020
2021
  });
2021
2022
  }
2022
2023
 
@@ -3075,9 +3076,13 @@ init_errors();
3075
3076
 
3076
3077
  // src/providers/cloudflare/models.ts
3077
3078
  init_errors();
3079
+
3080
+ // src/providers/cloudflare/model-ids.ts
3078
3081
  var CLOUDFLARE_IMAGE_MODEL = "@cf/black-forest-labs/flux-1-schnell";
3079
3082
  var CLOUDFLARE_TTS_MODEL = "@cf/myshell-ai/melotts";
3080
3083
  var CLOUDFLARE_VIDEO_MODEL = "prunaai/p-video";
3084
+
3085
+ // src/providers/cloudflare/models.ts
3081
3086
  function required(name) {
3082
3087
  const value = resolveKey(name)?.trim();
3083
3088
  if (!value) throw new ConfigError(`${name} is required for Cloudflare media generation`);
@@ -3121,6 +3126,68 @@ function requireCloudflareVideoConfig() {
3121
3126
  };
3122
3127
  }
3123
3128
 
3129
+ // src/providers/cloudflare/video-client.ts
3130
+ init_errors();
3131
+ function cloudflareProviderFailure(status) {
3132
+ return new ProviderError(`Cloudflare request failed with HTTP ${status}`, "Cloudflare");
3133
+ }
3134
+ async function readCloudflareJson(response) {
3135
+ if (!response.ok) throw cloudflareProviderFailure(response.status);
3136
+ try {
3137
+ return await response.json();
3138
+ } catch {
3139
+ throw new ProviderError("Cloudflare returned an invalid JSON response", "Cloudflare");
3140
+ }
3141
+ }
3142
+ function videoUrl(config, suffix = "") {
3143
+ return `https://gateway.ai.cloudflare.com/v1/${encodeURIComponent(config.accountId)}/${encodeURIComponent(config.gatewayId)}/replicate/predictions${suffix}`;
3144
+ }
3145
+ function videoHeaders(config, opts) {
3146
+ return {
3147
+ Authorization: `Bearer ${config.replicateApiToken}`,
3148
+ "cf-aig-authorization": `Bearer ${config.apiToken}`,
3149
+ "cf-aig-collect-log-payload": opts.collectLogPayload ? "true" : "false",
3150
+ "Content-Type": "application/json",
3151
+ ...opts.wait ? { Prefer: "wait" } : {}
3152
+ };
3153
+ }
3154
+ function send(opts, url, init) {
3155
+ const f = opts.fetch;
3156
+ const withTimeout = {
3157
+ ...init,
3158
+ signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS)
3159
+ };
3160
+ return f ? f(url, withTimeout) : globalThis.fetch(url, withTimeout);
3161
+ }
3162
+ function parseCloudflareVideoPrediction(value) {
3163
+ if (!value || typeof value !== "object") {
3164
+ throw new ProviderError("Cloudflare returned an invalid video prediction", "Cloudflare");
3165
+ }
3166
+ const result = value;
3167
+ if (typeof result.id !== "string" || typeof result.status !== "string") {
3168
+ throw new ProviderError("Cloudflare returned an invalid video prediction", "Cloudflare");
3169
+ }
3170
+ const output = result.output === null ? void 0 : result.output;
3171
+ if (output !== void 0 && typeof output !== "string" && (!Array.isArray(output) || !output.every((item) => typeof item === "string"))) {
3172
+ throw new ProviderError("Cloudflare returned an invalid video output", "Cloudflare");
3173
+ }
3174
+ return { id: result.id, status: result.status, output };
3175
+ }
3176
+ async function createCloudflareVideoPrediction(config, input, opts = {}) {
3177
+ const response = await send(opts, videoUrl(config), {
3178
+ method: "POST",
3179
+ headers: videoHeaders(config, opts),
3180
+ body: JSON.stringify({ version: CLOUDFLARE_VIDEO_MODEL, input })
3181
+ });
3182
+ return parseCloudflareVideoPrediction(await readCloudflareJson(response));
3183
+ }
3184
+ async function getCloudflareVideoPrediction(config, id, opts = {}) {
3185
+ const response = await send(opts, videoUrl(config, `/${encodeURIComponent(id)}`), {
3186
+ headers: videoHeaders(config, { ...opts, wait: false })
3187
+ });
3188
+ return parseCloudflareVideoPrediction(await readCloudflareJson(response));
3189
+ }
3190
+
3124
3191
  // src/providers/cloudflare/client.ts
3125
3192
  function collectPayloadHeader() {
3126
3193
  const envName = ["CLOUDFLARE", "AI", "GATEWAY", "COLLECT", "LOG", "PAYLOAD"].join("_");
@@ -3134,17 +3201,6 @@ function gatewayHeaders(config) {
3134
3201
  "cf-aig-collect-log-payload": collectPayloadHeader()
3135
3202
  };
3136
3203
  }
3137
- function providerFailure(status) {
3138
- return new ProviderError(`Cloudflare request failed with HTTP ${status}`, "Cloudflare");
3139
- }
3140
- async function readJson(response) {
3141
- if (!response.ok) throw providerFailure(response.status);
3142
- try {
3143
- return await response.json();
3144
- } catch {
3145
- throw new ProviderError("Cloudflare returned an invalid JSON response", "Cloudflare");
3146
- }
3147
- }
3148
3204
  function nativeUrl(accountId, model) {
3149
3205
  return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/ai/run/${model}`;
3150
3206
  }
@@ -3162,7 +3218,7 @@ async function runCloudflareJson(model, input) {
3162
3218
  body: JSON.stringify(useGateway ? { model, input } : input)
3163
3219
  }
3164
3220
  );
3165
- const payload = await readJson(response);
3221
+ const payload = await readCloudflareJson(response);
3166
3222
  if (payload.success === false || payload.result === void 0) {
3167
3223
  throw new ProviderError("Cloudflare returned no usable result", "Cloudflare");
3168
3224
  }
@@ -3179,53 +3235,23 @@ async function runCloudflareBytes(model, input) {
3179
3235
  body: JSON.stringify(useGateway ? { model, input } : input)
3180
3236
  }
3181
3237
  );
3182
- if (!response.ok) throw providerFailure(response.status);
3238
+ if (!response.ok) throw cloudflareProviderFailure(response.status);
3183
3239
  const contentType = response.headers.get("content-type") ?? "";
3184
3240
  if (!contentType.includes("audio/mpeg")) {
3185
3241
  throw new ProviderError("Cloudflare did not return MPEG audio", "Cloudflare");
3186
3242
  }
3187
3243
  return new Uint8Array(await response.arrayBuffer());
3188
3244
  }
3189
- function videoUrl(config, suffix = "") {
3190
- return `https://gateway.ai.cloudflare.com/v1/${encodeURIComponent(config.accountId)}/${encodeURIComponent(config.gatewayId)}/replicate/predictions${suffix}`;
3191
- }
3192
- function videoHeaders(config, wait = false) {
3193
- return {
3194
- Authorization: `Bearer ${config.replicateApiToken}`,
3195
- "cf-aig-authorization": `Bearer ${config.apiToken}`,
3196
- "cf-aig-collect-log-payload": collectPayloadHeader(),
3197
- "Content-Type": "application/json",
3198
- ...wait ? { Prefer: "wait" } : {}
3199
- };
3200
- }
3201
- function prediction(value) {
3202
- if (!value || typeof value !== "object") {
3203
- throw new ProviderError("Cloudflare returned an invalid video prediction", "Cloudflare");
3204
- }
3205
- const result = value;
3206
- if (typeof result.id !== "string" || typeof result.status !== "string") {
3207
- throw new ProviderError("Cloudflare returned an invalid video prediction", "Cloudflare");
3208
- }
3209
- if (result.output !== void 0 && typeof result.output !== "string" && (!Array.isArray(result.output) || !result.output.every((item) => typeof item === "string"))) {
3210
- throw new ProviderError("Cloudflare returned an invalid video output", "Cloudflare");
3211
- }
3212
- return { id: result.id, status: result.status, output: result.output };
3213
- }
3214
3245
  async function createVideoPrediction(input, wait = false) {
3215
- const config = requireCloudflareVideoConfig();
3216
- const response = await globalThis.fetch(videoUrl(config), {
3217
- method: "POST",
3218
- headers: videoHeaders(config, wait),
3219
- body: JSON.stringify({ version: CLOUDFLARE_VIDEO_MODEL, input })
3246
+ return createCloudflareVideoPrediction(requireCloudflareVideoConfig(), input, {
3247
+ wait,
3248
+ collectLogPayload: collectPayloadHeader() === "true"
3220
3249
  });
3221
- return prediction(await readJson(response));
3222
3250
  }
3223
3251
  async function getVideoPrediction(id) {
3224
- const config = requireCloudflareVideoConfig();
3225
- const response = await globalThis.fetch(videoUrl(config, `/${encodeURIComponent(id)}`), {
3226
- headers: videoHeaders(config)
3252
+ return getCloudflareVideoPrediction(requireCloudflareVideoConfig(), id, {
3253
+ collectLogPayload: collectPayloadHeader() === "true"
3227
3254
  });
3228
- return prediction(await readJson(response));
3229
3255
  }
3230
3256
  async function downloadVideo2(url) {
3231
3257
  let response;
@@ -3302,20 +3328,20 @@ function positiveInteger(value, flag) {
3302
3328
  throw new ValidationError(`${flag} must be a positive integer`);
3303
3329
  return parsed;
3304
3330
  }
3305
- function outputUrl(prediction2) {
3306
- if (typeof prediction2.output === "string") return prediction2.output;
3307
- return prediction2.output?.find((value) => typeof value === "string");
3331
+ function outputUrl(prediction) {
3332
+ if (typeof prediction.output === "string") return prediction.output;
3333
+ return prediction.output?.find((value) => typeof value === "string");
3308
3334
  }
3309
- async function saveCompletedVideo(prediction2, output) {
3310
- const url = outputUrl(prediction2);
3335
+ async function saveCompletedVideo(prediction, output) {
3336
+ const url = outputUrl(prediction);
3311
3337
  if (!url)
3312
3338
  throw new ProviderError("Cloudflare completed the video without an output", "Cloudflare");
3313
3339
  const bytes = await downloadVideo2(url);
3314
- return saveBytes(bytes, `cloudflare_video_${prediction2.id}.mp4`, output);
3340
+ return saveBytes(bytes, `cloudflare_video_${prediction.id}.mp4`, output);
3315
3341
  }
3316
- async function waitForVideo(prediction2, timeoutMs, onTick) {
3342
+ async function waitForVideo(prediction, timeoutMs, onTick) {
3317
3343
  const deadline = Date.now() + timeoutMs;
3318
- let current = prediction2;
3344
+ let current = prediction;
3319
3345
  while (!isTerminalVideoStatus(current.status)) {
3320
3346
  if (Date.now() >= deadline)
3321
3347
  throw new ProviderError("Cloudflare video polling timed out", "Cloudflare");
@@ -3347,15 +3373,15 @@ function registerCloudflareVideoCommand(parent) {
3347
3373
  fps: positiveInteger(opts.fps, "--fps")
3348
3374
  };
3349
3375
  const shouldWait = Boolean(opts.wait || opts.download);
3350
- const prediction2 = await createVideoPrediction(input, shouldWait);
3351
- logger.success(`Video job ${prediction2.id} (${prediction2.status})`);
3376
+ const prediction = await createVideoPrediction(input, shouldWait);
3377
+ logger.success(`Video job ${prediction.id} (${prediction.status})`);
3352
3378
  if (!shouldWait) {
3353
- console.log(prediction2.id);
3354
- console.error(`Poll with: multix cloudflare video-status ${prediction2.id}`);
3379
+ console.log(prediction.id);
3380
+ console.error(`Poll with: multix cloudflare video-status ${prediction.id}`);
3355
3381
  return;
3356
3382
  }
3357
- const completed = prediction2.status === "succeeded" ? prediction2 : await waitForVideo(
3358
- prediction2,
3383
+ const completed = prediction.status === "succeeded" ? prediction : await waitForVideo(
3384
+ prediction,
3359
3385
  positiveInteger(opts.waitTimeout, "--wait-timeout"),
3360
3386
  (status) => logger.debug(`Video status: ${status}`)
3361
3387
  );
@@ -3405,12 +3431,12 @@ function registerCloudflareVideoStatusCommand(parent) {
3405
3431
  async (id, opts) => {
3406
3432
  const logger = createLogger({ verbose: opts.verbose ?? false });
3407
3433
  const shouldWait = Boolean(opts.wait || opts.download);
3408
- const prediction2 = await getVideoPrediction(id);
3409
- const completed = shouldWait && prediction2.status !== "succeeded" ? await waitForVideo(
3410
- prediction2,
3434
+ const prediction = await getVideoPrediction(id);
3435
+ const completed = shouldWait && prediction.status !== "succeeded" ? await waitForVideo(
3436
+ prediction,
3411
3437
  positiveInteger(opts.waitTimeout, "--wait-timeout"),
3412
3438
  (status) => logger.debug(`Video status: ${status}`)
3413
- ) : prediction2;
3439
+ ) : prediction;
3414
3440
  console.log(`${completed.id}: ${completed.status}`);
3415
3441
  if (opts.download) {
3416
3442
  if (completed.status !== "succeeded") {
@@ -10612,13 +10638,13 @@ var ReplicateApiError = class extends ProviderError {
10612
10638
  retryAfterMs;
10613
10639
  };
10614
10640
  var ReplicatePredictionError = class extends ProviderError {
10615
- constructor(prediction2) {
10616
- const detail = formatPredictionError(prediction2.error);
10641
+ constructor(prediction) {
10642
+ const detail = formatPredictionError(prediction.error);
10617
10643
  super(
10618
- `Replicate prediction ${prediction2.id} ${prediction2.status}${detail ? `: ${detail}` : ""}`,
10644
+ `Replicate prediction ${prediction.id} ${prediction.status}${detail ? `: ${detail}` : ""}`,
10619
10645
  PROVIDER
10620
10646
  );
10621
- this.prediction = prediction2;
10647
+ this.prediction = prediction;
10622
10648
  this.name = "ReplicatePredictionError";
10623
10649
  }
10624
10650
  prediction;
@@ -10687,11 +10713,11 @@ function validatePrediction(value) {
10687
10713
  }
10688
10714
  return value;
10689
10715
  }
10690
- function assertSucceeded(prediction2) {
10691
- if (prediction2.status === "succeeded") return prediction2;
10692
- if (isTerminalStatus2(prediction2.status)) throw new ReplicatePredictionError(prediction2);
10716
+ function assertSucceeded(prediction) {
10717
+ if (prediction.status === "succeeded") return prediction;
10718
+ if (isTerminalStatus2(prediction.status)) throw new ReplicatePredictionError(prediction);
10693
10719
  throw new ProviderError(
10694
- `Replicate prediction ${prediction2.id} is not finished (status: ${prediction2.status})`,
10720
+ `Replicate prediction ${prediction.id} is not finished (status: ${prediction.status})`,
10695
10721
  PROVIDER
10696
10722
  );
10697
10723
  }
@@ -10772,9 +10798,9 @@ var ReplicateClient = class {
10772
10798
  * canceled, aborted) and return it — callers decide success via
10773
10799
  * assertSucceeded. Retries network/429/5xx; throws PollTimeoutError on deadline.
10774
10800
  */
10775
- async waitForPrediction(prediction2, opts) {
10776
- if (typeof prediction2 !== "string" && isTerminalStatus2(prediction2.status)) return prediction2;
10777
- const safeId = assertPredictionId(typeof prediction2 === "string" ? prediction2 : prediction2.id);
10801
+ async waitForPrediction(prediction, opts) {
10802
+ if (typeof prediction !== "string" && isTerminalStatus2(prediction.status)) return prediction;
10803
+ const safeId = assertPredictionId(typeof prediction === "string" ? prediction : prediction.id);
10778
10804
  try {
10779
10805
  return await pollUntil({
10780
10806
  fetchStatus: async ({ remainingMs }) => validatePrediction(
@@ -10877,13 +10903,13 @@ function registerReplicateCancelCommand(parent) {
10877
10903
  parent.command("cancel <predictionId>").description("Cancel a running Replicate prediction").option("-v, --verbose", "Verbose logging").action(async (predictionId, opts) => {
10878
10904
  const logger = createLogger({ verbose: opts.verbose ?? false });
10879
10905
  const client = createReplicateClient();
10880
- const prediction2 = await client.cancelPrediction(predictionId);
10881
- if (prediction2.status === "canceled") {
10882
- logger.success(`Prediction ${prediction2.id} canceled`);
10883
- } else if (isTerminalStatus2(prediction2.status)) {
10884
- logger.warn(`Prediction ${prediction2.id} already finished (status: ${prediction2.status})`);
10906
+ const prediction = await client.cancelPrediction(predictionId);
10907
+ if (prediction.status === "canceled") {
10908
+ logger.success(`Prediction ${prediction.id} canceled`);
10909
+ } else if (isTerminalStatus2(prediction.status)) {
10910
+ logger.warn(`Prediction ${prediction.id} already finished (status: ${prediction.status})`);
10885
10911
  } else {
10886
- logger.info(`Cancel requested for ${prediction2.id} (status: ${prediction2.status})`);
10912
+ logger.info(`Cancel requested for ${prediction.id} (status: ${prediction.status})`);
10887
10913
  }
10888
10914
  });
10889
10915
  }
@@ -11283,42 +11309,42 @@ function parsePositiveInt2(raw, flag, max) {
11283
11309
  function resumeCommand3(id) {
11284
11310
  return `multix replicate status ${id} --wait --download`;
11285
11311
  }
11286
- function printAsyncHandle(prediction2) {
11287
- console.log(`Prediction submitted: ${prediction2.id} (status: ${prediction2.status})`);
11288
- console.log(` Check: multix replicate status ${prediction2.id}`);
11289
- console.log(` Resume: ${resumeCommand3(prediction2.id)}`);
11290
- console.log(` Cancel: multix replicate cancel ${prediction2.id}`);
11312
+ function printAsyncHandle(prediction) {
11313
+ console.log(`Prediction submitted: ${prediction.id} (status: ${prediction.status})`);
11314
+ console.log(` Check: multix replicate status ${prediction.id}`);
11315
+ console.log(` Resume: ${resumeCommand3(prediction.id)}`);
11316
+ console.log(` Cancel: multix replicate cancel ${prediction.id}`);
11291
11317
  }
11292
- async function reportOutputs(prediction2, opts) {
11293
- const { media, texts } = extractOutput(prediction2.output);
11318
+ async function reportOutputs(prediction, opts) {
11319
+ const { media, texts } = extractOutput(prediction.output);
11294
11320
  for (const text of texts) console.log(text);
11295
11321
  if (media.length === 0) {
11296
- if (texts.length === 0) console.log(JSON.stringify(summarizePrediction(prediction2), null, 2));
11322
+ if (texts.length === 0) console.log(JSON.stringify(summarizePrediction(prediction), null, 2));
11297
11323
  return [];
11298
11324
  }
11299
11325
  if (!opts.download) {
11300
11326
  for (const m of media) console.log(m.url);
11301
11327
  return [];
11302
11328
  }
11303
- const saved = await saveMediaOutputs(prediction2.id, media, {
11329
+ const saved = await saveMediaOutputs(prediction.id, media, {
11304
11330
  outDir: opts.output ? "" : getOutputDir(),
11305
11331
  output: opts.output,
11306
11332
  logger: opts.logger,
11307
- retryHint: `Retry while the output URLs are live: multix replicate status ${prediction2.id} --download`
11333
+ retryHint: `Retry while the output URLs are live: multix replicate status ${prediction.id} --download`
11308
11334
  });
11309
11335
  const finals = opts.finalize ? opts.finalize(saved) : saved;
11310
11336
  console.log(`
11311
- Saved ${finals.length} file(s) from prediction ${prediction2.id}:`);
11337
+ Saved ${finals.length} file(s) from prediction ${prediction.id}:`);
11312
11338
  for (const f of finals) console.log(` ${f}`);
11313
11339
  return finals;
11314
11340
  }
11315
- async function completePrediction(client, prediction2, opts) {
11316
- const id = typeof prediction2 === "string" ? prediction2 : prediction2.id;
11317
- let lastStatus = typeof prediction2 === "string" ? "" : prediction2.status;
11318
- if (typeof prediction2 === "string" || !isTerminalStatus2(prediction2.status)) {
11341
+ async function completePrediction(client, prediction, opts) {
11342
+ const id = typeof prediction === "string" ? prediction : prediction.id;
11343
+ let lastStatus = typeof prediction === "string" ? "" : prediction.status;
11344
+ if (typeof prediction === "string" || !isTerminalStatus2(prediction.status)) {
11319
11345
  opts.logger.info(`Waiting for prediction ${id}...`);
11320
11346
  }
11321
- const final = await client.waitForPrediction(prediction2, {
11347
+ const final = await client.waitForPrediction(prediction, {
11322
11348
  timeoutMs: opts.waitTimeoutMs,
11323
11349
  resumeHint: `Resume with: ${resumeCommand3(id)}`,
11324
11350
  onPoll: (p, attempt) => {
@@ -11339,17 +11365,17 @@ async function executePrediction2(params) {
11339
11365
  }
11340
11366
  const waitTimeoutMs = parsePositiveInt2(opts.waitTimeout, "--wait-timeout");
11341
11367
  logger.info(`Submitting to Replicate ${describeModelRef(model)}...`);
11342
- const prediction2 = await client.createPrediction({
11368
+ const prediction = await client.createPrediction({
11343
11369
  model,
11344
11370
  input,
11345
11371
  preferWaitSeconds: opts.preferWait
11346
11372
  });
11347
11373
  if (opts.async) {
11348
- printAsyncHandle(prediction2);
11374
+ printAsyncHandle(prediction);
11349
11375
  return;
11350
11376
  }
11351
- logger.info(`Prediction ${prediction2.id} (status: ${prediction2.status})`);
11352
- await completePrediction(client, prediction2, {
11377
+ logger.info(`Prediction ${prediction.id} (status: ${prediction.status})`);
11378
+ await completePrediction(client, prediction, {
11353
11379
  waitTimeoutMs,
11354
11380
  logger,
11355
11381
  download: opts.download !== false,
@@ -11501,16 +11527,16 @@ function registerReplicateStatusCommand(parent) {
11501
11527
  });
11502
11528
  return;
11503
11529
  }
11504
- const prediction2 = await client.getPrediction(predictionId);
11505
- if (prediction2.status === "succeeded" && opts.download) {
11506
- await reportOutputs(prediction2, { download: true, output: opts.output, logger });
11530
+ const prediction = await client.getPrediction(predictionId);
11531
+ if (prediction.status === "succeeded" && opts.download) {
11532
+ await reportOutputs(prediction, { download: true, output: opts.output, logger });
11507
11533
  return;
11508
11534
  }
11509
- console.log(JSON.stringify(summarizePrediction(prediction2), null, 2));
11510
- if (!isTerminalStatus2(prediction2.status)) {
11511
- logger.info(`Still running. Resume with: ${resumeCommand3(prediction2.id)}`);
11512
- } else if (prediction2.status !== "succeeded") {
11513
- throw new ReplicatePredictionError(prediction2);
11535
+ console.log(JSON.stringify(summarizePrediction(prediction), null, 2));
11536
+ if (!isTerminalStatus2(prediction.status)) {
11537
+ logger.info(`Still running. Resume with: ${resumeCommand3(prediction.id)}`);
11538
+ } else if (prediction.status !== "succeeded") {
11539
+ throw new ReplicatePredictionError(prediction);
11514
11540
  }
11515
11541
  });
11516
11542
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrgoonie/multix",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "description": "AI multimodal CLI — OpenAI, Gemini, MiniMax, OpenRouter, Leonardo, BytePlus, ElevenLabs, fal.ai, Atlas Cloud, Replicate, HeyGen, ffmpeg, ImageMagick, doc-to-md",
5
5
  "type": "module",
6
6
  "bin": {
@@ -35,7 +35,11 @@
35
35
  "test": "vitest run",
36
36
  "test:watch": "vitest",
37
37
  "test:smoke": "vitest run tests/smoke",
38
- "verify:docs": "npm run verify --prefix docs-site"
38
+ "verify:docs": "npm run verify --prefix docs-site",
39
+ "dev:mcp": "npm run dev --prefix worker",
40
+ "deploy:mcp": "npm run deploy --prefix worker",
41
+ "test:mcp": "npm test --prefix worker",
42
+ "check:mcp": "npm run typecheck --prefix worker && npm test --prefix worker && npm run check:bundle --prefix worker"
39
43
  },
40
44
  "keywords": [
41
45
  "cli",
package/skill/SKILL.md CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: multix
3
3
  description: AI multimodal CLI — generate and edit images (OpenAI, Codex driver, Gemini Nano Banana, Imagen, MiniMax, OpenRouter, Leonardo, BytePlus Seedream, Cloudflare Workers AI FLUX.1 Schnell, fal.ai FLUX, Replicate FLUX), generate video (Gemini Veo, MiniMax Hailuo, OpenRouter via Veo/Seedance/Kling, Leonardo via Motion/Veo/Kling/Hailuo/Seedance/LTX, BytePlus Seedance, Cloudflare AI Gateway via Replicate, fal.ai queue models, Replicate direct API models incl. P-Video, HeyGen Video Agent and scripted avatar videos), video translation and lipsync (HeyGen), TTS/STT/music (OpenAI, Gemini Flash TTS incl. Gemini 3.8 with delivery-style control, MiniMax, Cloudflare Workers AI MeloTTS, ElevenLabs), 3D models (BytePlus Hyper3D/Hitem3d), analyze/transcribe media, convert documents to Markdown, optimize media via ffmpeg/ImageMagick, and self-update the CLI. Use whenever the user wants to create, edit, or transform images/video/audio/3D/documents from a CLI, mentions any of: OpenAI, Codex image driver, image-to-image, i2i, image edit, watercolor/cyberpunk style transfer, reference image, OpenRouter, Nano Banana, Flux, Seedream, Veo, Hailuo, Seedance, Kling, Cloudflare Workers AI, AI Gateway, fal.ai, Atlas Cloud, Replicate, replicate.com models, Replicate predictions, HeyGen, avatar video, talking head, Video Agent, video translation, dubbing, lipsync, video generation, text-to-video, image-to-video, i2v, ElevenLabs voice cloning, updating/upgrading multix, or asks for batch media optimization.
4
- version: 0.14.1 # x-release-please-version
4
+ version: 0.15.0 # x-release-please-version
5
5
  ---
6
6
 
7
7
  # multix — AI Multimodal CLI
@@ -466,6 +466,19 @@ multix update --check
466
466
  multix update
467
467
  ```
468
468
 
469
+ ## Remote MCP server
470
+
471
+ If the user cannot run the CLI locally but has deployed the optional multix
472
+ MCP Worker (`worker/` in the repo; guide: https://multix.zuey.me/guides/mcp-server/),
473
+ call its tools instead: `multix_list_providers` first, then
474
+ `multix_generate_image` / `multix_generate_video`, and poll
475
+ `multix_prediction_status` until `done` is true. It returns provider URLs (no
476
+ local files), supports only Replicate, Atlas Cloud, HeyGen, and Cloudflare
477
+ video, and accepts only public `https://` image URLs. Check `limits` from
478
+ `multix_list_providers` before a video call: `duration` above
479
+ `max_video_seconds` or a `resolution` outside `allowed_resolutions` is
480
+ rejected, and an omitted `duration` becomes `default_video_seconds`.
481
+
469
482
  ## Security & scope
470
483
 
471
484
  This skill produces shell commands for the local `multix` CLI. It does NOT execute the commands itself, exfiltrate API keys, or modify provider accounts.