@mrgoonie/multix 0.15.3 → 1.0.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,17 @@ 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
+ ## [1.0.0](https://github.com/mrgoonie/multix-cli/compare/v0.15.3...v1.0.0) (2026-10-11)
9
+
10
+
11
+ ### ⚠ BREAKING CHANGES
12
+
13
+ * **core:** a project `.env` can no longer redirect a provider base URL or Cloudflare routing ID while the API key comes from the shell or `~/.multix/.env`. Keep the key in the same project `.env`, move the override to `~/.multix/.env`, or opt in explicitly as described in the environment reference.
14
+
15
+ ### Fixed
16
+
17
+ * **core:** only trust project .env endpoint overrides that ship their own key ([#97](https://github.com/mrgoonie/multix-cli/issues/97)) ([5c503ec](https://github.com/mrgoonie/multix-cli/commit/5c503eca08aeca6998cf5cf520180a32c0aeee0c))
18
+
8
19
  ## [0.15.3](https://github.com/mrgoonie/multix-cli/compare/v0.15.2...v0.15.3) (2026-10-11)
9
20
 
10
21
 
package/README.md CHANGED
@@ -117,6 +117,8 @@ Set extra keys as `GEMINI_API_KEY_2`, `GEMINI_API_KEY_3`, … (any numeric suffi
117
117
 
118
118
  Priority: `process.env` > `cwd/.env` > `~/.multix/.env`. Set `MULTIX_LEGACY_CLAUDE_ENV=1` to also read legacy AgentKit files (`.claude/skills/{ai-multimodal,ak-ai-multimodal}/.env`, `.claude/skills/.env`, `.claude/.env`, project first, then home) with the lowest priority (credential names only, never endpoint/proxy settings); `multix check -v` lists the env files that were loaded.
119
119
 
120
+ `*_BASE_URL`, `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_AI_GATEWAY_ID` decide where your API key is sent. Values from the shell or `~/.multix/.env` are always used. A value from the project `.env` (or a legacy `.claude` file under it) is used only when every key the provider sends with it comes from that same project file (the repository's own key). That protects your key, not your data: a project `.env` that ships its own key and base URL routes that project's requests, including your prompts and any files you upload, to that host, and its key replaces yours from `~/.multix/.env`, so review a cloned repository's `.env` before running `multix` in it. If a key comes from anywhere else, the project value is ignored: the `~/.multix/.env` value is used with a warning (variable, file and key source named, never the value), or, if there is none, the command stops with an error instead of falling back to the vendor default. `multix check` shows whether each such setting is in effect or ignored. To use a project value for one command, run `MULTIX_ALLOW_PROJECT_BASE_URL=1 multix ...`; do not put the opt-in in `~/.multix/.env`, which disables the check for every directory. Non-default base URLs must be `https://` (plain `http://` only for `localhost`, `127.0.0.1`, `[::1]`), and `multix` warns once per variable on stderr with the provider and hostname only. This covers base URLs and Cloudflare routing IDs only; other variables a project `.env` can set are tracked in [#99](https://github.com/mrgoonie/multix-cli/issues/99).
121
+
120
122
  ## Commands
121
123
 
122
124
  ### `multix check`
package/dist/cli.js CHANGED
@@ -308,6 +308,11 @@ function validateRawInput(spec, body) {
308
308
 
309
309
  // src/providers/atlascloud/models.ts
310
310
  var DEFAULT_ATLAS_BASE_URL = "https://api.atlascloud.ai/api/v1";
311
+ var ATLAS_ROUTING = {
312
+ name: "ATLASCLOUD_BASE_URL",
313
+ defaultUrl: DEFAULT_ATLAS_BASE_URL,
314
+ keyEnvs: ["ATLASCLOUD_API_KEY"]
315
+ };
311
316
  var SUBMIT_PATHS = {
312
317
  image: "/model/generateImage",
313
318
  video: "/model/generateVideo",
@@ -339,13 +344,20 @@ import fs2 from "fs/promises";
339
344
  import path2 from "path";
340
345
 
341
346
  // src/core/env-loader.ts
347
+ init_errors();
342
348
  init_redact();
343
349
  import fs from "fs";
344
350
  import os from "os";
345
351
  import path from "path";
346
- import { config as dotenvConfig, parse as dotenvParse } from "dotenv";
352
+ import { parse as dotenvParse } from "dotenv";
353
+ function envNameKey(name, platform = process.platform) {
354
+ return platform === "win32" ? name.toUpperCase() : name;
355
+ }
347
356
  var loaded = false;
348
357
  var loadedFiles = [];
358
+ var fileOrigins = /* @__PURE__ */ new Map();
359
+ var userFileValues = {};
360
+ var warned = /* @__PURE__ */ new Set();
349
361
  var LEGACY_SKILL_DIRS = ["ai-multimodal", "ak-ai-multimodal"];
350
362
  function legacyFilesUnder(root) {
351
363
  const claude = path.join(root, ".claude");
@@ -368,8 +380,7 @@ function legacyClaudeEnvFiles(cwd, home) {
368
380
  return files;
369
381
  }
370
382
  function legacyClaudeEnvEnabled(env = process.env) {
371
- const value = env.MULTIX_LEGACY_CLAUDE_ENV;
372
- return value !== void 0 && ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
383
+ return isTruthy(env.MULTIX_LEGACY_CLAUDE_ENV);
373
384
  }
374
385
  var LEGACY_ALLOWED_NAME = /^[A-Z][A-Z0-9_]*_(API_KEY(_\d+)?|API_TOKEN|ACCOUNT_ID|GATEWAY_ID)$|^(FAL_KEY|ARK_API_KEY)$/;
375
386
  function isLegacyAllowedName(name) {
@@ -382,35 +393,173 @@ function isReadableFile(file) {
382
393
  return false;
383
394
  }
384
395
  }
385
- function loadFile(file) {
386
- if (!isReadableFile(file)) return;
387
- const result = dotenvConfig({ path: file, override: false });
388
- if (!result.error) loadedFiles.push(file);
389
- }
390
- function loadLegacyFile(file) {
391
- if (!isReadableFile(file)) return;
392
- let parsed;
396
+ function parseFile(file) {
397
+ if (!isReadableFile(file)) return void 0;
393
398
  try {
394
- parsed = dotenvParse(fs.readFileSync(file));
399
+ return dotenvParse(fs.readFileSync(file));
395
400
  } catch {
396
- return;
401
+ return void 0;
397
402
  }
403
+ }
404
+ function loadFile(file, source, accept = () => true) {
405
+ const parsed = parseFile(file);
406
+ if (!parsed) return void 0;
398
407
  for (const [name, value] of Object.entries(parsed)) {
399
- if (isLegacyAllowedName(name) && process.env[name] === void 0) process.env[name] = value;
408
+ if (!accept(name) || process.env[name] !== void 0) continue;
409
+ process.env[name] = value;
410
+ fileOrigins.set(envNameKey(name), { source, file, value });
400
411
  }
401
412
  loadedFiles.push(file);
413
+ return parsed;
414
+ }
415
+ function byNameKey(values) {
416
+ const out = {};
417
+ for (const [name, value] of Object.entries(values ?? {})) {
418
+ const key = envNameKey(name);
419
+ if (!(key in out)) out[key] = value;
420
+ }
421
+ return out;
402
422
  }
403
423
  function loadEnv() {
404
424
  if (loaded) return;
405
425
  loaded = true;
406
426
  loadedFiles = [];
427
+ fileOrigins = /* @__PURE__ */ new Map();
428
+ userFileValues = {};
407
429
  const cwd = process.cwd();
408
430
  const home = process.env.MULTIX_DISABLE_HOME_ENV ? void 0 : os.homedir();
409
- loadFile(path.resolve(cwd, ".env"));
410
- if (home) loadFile(path.join(home, ".multix", ".env"));
431
+ loadFile(path.resolve(cwd, ".env"), "project");
432
+ if (home) userFileValues = byNameKey(loadFile(path.join(home, ".multix", ".env"), "user"));
411
433
  if (legacyClaudeEnvEnabled()) {
412
- for (const file of legacyClaudeEnvFiles(cwd, home)) loadLegacyFile(file);
434
+ const projectLegacy = new Set(legacyFilesUnder(cwd));
435
+ for (const file of legacyClaudeEnvFiles(cwd, home)) {
436
+ loadFile(file, projectLegacy.has(file) ? "project" : "legacy", isLegacyAllowedName);
437
+ }
438
+ }
439
+ }
440
+ function envFileOrigin(name) {
441
+ const origin = fileOrigins.get(envNameKey(name));
442
+ if (!origin || process.env[name] !== origin.value) return void 0;
443
+ return { source: origin.source, file: origin.file };
444
+ }
445
+ function isTruthy(value) {
446
+ return value !== void 0 && ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
447
+ }
448
+ var ALLOW_PROJECT_BASE_URL_ENV = "MULTIX_ALLOW_PROJECT_BASE_URL";
449
+ function projectBaseUrlAllowed() {
450
+ if (!isTruthy(process.env[ALLOW_PROJECT_BASE_URL_ENV])) return false;
451
+ return envFileOrigin(ALLOW_PROJECT_BASE_URL_ENV)?.source !== "project";
452
+ }
453
+ var OPT_IN_HINT = `To use the project value for one command, run it as \`${ALLOW_PROJECT_BASE_URL_ENV}=1 multix ...\`. Do not put ${ALLOW_PROJECT_BASE_URL_ENV} in ~/.multix/.env: that trusts the .env of every directory you run multix in.`;
454
+ function describeSource(origin) {
455
+ if (!origin) return "the shell";
456
+ if (origin.source === "user") return "~/.multix/.env";
457
+ if (origin.source === "legacy") return origin.file;
458
+ return `another project file (${origin.file})`;
459
+ }
460
+ function foreignCredential(credentials, file) {
461
+ for (const spec of credentials) {
462
+ const names = typeof spec === "string" ? [spec] : spec;
463
+ const used = names.find((n) => process.env[n]?.trim());
464
+ if (!used) continue;
465
+ const origin = envFileOrigin(used);
466
+ if (origin?.source === "project" && origin.file === file) continue;
467
+ return `${used} comes from ${describeSource(origin)}`;
413
468
  }
469
+ return void 0;
470
+ }
471
+ function inspectTrustedSetting(name, credentials, harmless) {
472
+ const value = process.env[name]?.trim() || void 0;
473
+ const origin = envFileOrigin(name);
474
+ if (origin?.source !== "project") {
475
+ return value ? { status: "used", value, source: origin?.source ?? "shell" } : { status: "unset" };
476
+ }
477
+ const userValue = userFileValues[envNameKey(name)]?.trim() || void 0;
478
+ if (value === void 0) {
479
+ return userValue ? { status: "fallback", value: userValue, file: origin.file, reason: "it is empty" } : { status: "unset" };
480
+ }
481
+ const project = (basis) => ({
482
+ status: "used",
483
+ value,
484
+ source: "project",
485
+ basis
486
+ });
487
+ if (value === harmless) return project("default");
488
+ if (projectBaseUrlAllowed()) return project("opt-in");
489
+ const reason = foreignCredential(credentials, origin.file);
490
+ if (!reason) return project("same-file");
491
+ return userValue ? { status: "fallback", value: userValue, file: origin.file, reason } : { status: "refused", file: origin.file, reason };
492
+ }
493
+ function trustedSettingRefusal(name, state) {
494
+ return `${name} is set in project env file ${state.file}, but ${state.reason}, so the project is not trusted to choose where that key is sent. Set ${name} in your shell or ~/.multix/.env, or keep the key in the same project file. ${OPT_IN_HINT}`;
495
+ }
496
+ function applyTrustedSetting(name, state) {
497
+ if (state.status === "unset") return void 0;
498
+ if (state.status === "used") return state.value;
499
+ if (state.status === "refused") throw new ConfigError(trustedSettingRefusal(name, state));
500
+ const hint = state.reason === "it is empty" ? "" : ` ${OPT_IN_HINT}`;
501
+ warnOnce(
502
+ `ignored:${name}`,
503
+ `Ignoring ${name} from project env file ${state.file} (${state.reason}); using the value from ~/.multix/.env.${hint}`
504
+ );
505
+ return state.value;
506
+ }
507
+ function warnOnce(key, message) {
508
+ if (warned.has(key)) return;
509
+ warned.add(key);
510
+ console.warn(`\u26A0 ${message}`);
511
+ }
512
+ function hostOf(url) {
513
+ try {
514
+ return new URL(url).hostname || "(no host)";
515
+ } catch {
516
+ return "(unparseable URL)";
517
+ }
518
+ }
519
+ var stripSlash = (url) => url.replace(/\/+$/, "");
520
+ var LOOPBACK_HTTP_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
521
+ function baseUrlSchemeError(name, value) {
522
+ let url;
523
+ try {
524
+ url = new URL(value);
525
+ } catch {
526
+ return `${name} must be an absolute https:// URL`;
527
+ }
528
+ if (url.protocol === "https:") return void 0;
529
+ if (url.protocol === "http:" && LOOPBACK_HTTP_HOSTS.has(url.hostname)) return void 0;
530
+ return `${name} must use https:// (host ${url.hostname || "(none)"}); plain http:// is only allowed for localhost, 127.0.0.1, or [::1]`;
531
+ }
532
+ function defaultHarmless(name, defaultUrl) {
533
+ const raw = process.env[name]?.trim();
534
+ return raw && stripSlash(raw) === stripSlash(defaultUrl) ? raw : void 0;
535
+ }
536
+ function inspectBaseUrl(name, defaultUrl, credentials) {
537
+ const state = inspectTrustedSetting(name, credentials, defaultHarmless(name, defaultUrl));
538
+ if (state.status !== "used" && state.status !== "fallback") return state;
539
+ if (stripSlash(state.value) === stripSlash(defaultUrl)) return state;
540
+ const schemeError = baseUrlSchemeError(name, state.value);
541
+ return schemeError ? { ...state, schemeError } : state;
542
+ }
543
+ function inspectProviderBaseUrl(routing) {
544
+ return inspectBaseUrl(routing.name, routing.defaultUrl, [routing.keyEnvs]);
545
+ }
546
+ function resolveBaseUrl(name, defaultUrl, credentials) {
547
+ const state = inspectBaseUrl(name, defaultUrl, credentials);
548
+ const value = applyTrustedSetting(name, state);
549
+ if (!value || stripSlash(value) === stripSlash(defaultUrl)) return defaultUrl;
550
+ if (state.schemeError) throw new ConfigError(state.schemeError);
551
+ const provider = name.replace(/_BASE_URL$/, "");
552
+ warnOnce(
553
+ `custom:${name}`,
554
+ `${name} is set: ${provider} API requests, including your API key, go to ${hostOf(value)} instead of the default host.`
555
+ );
556
+ return value;
557
+ }
558
+ function resolveProviderBaseUrl(routing) {
559
+ return resolveBaseUrl(routing.name, routing.defaultUrl, [routing.keyEnvs]);
560
+ }
561
+ function resolveRoutedKey(routing) {
562
+ return resolveFirstKey(...routing.keyEnvs)?.value;
414
563
  }
415
564
  function getLoadedEnvFiles() {
416
565
  return [...loadedFiles];
@@ -1158,12 +1307,17 @@ var MODEL_ENV = {
1158
1307
  function atlasDefaultModel(key) {
1159
1308
  return resolveKey(MODEL_ENV[key])?.trim() || ATLAS_DEFAULT_MODELS[key];
1160
1309
  }
1161
- function createAtlasClientFromEnv() {
1162
- const apiKey = resolveKey("ATLASCLOUD_API_KEY")?.trim();
1310
+ function requireAtlasKey() {
1311
+ const apiKey = resolveRoutedKey(ATLAS_ROUTING);
1163
1312
  if (!apiKey) {
1164
1313
  throw new ConfigError(`ATLASCLOUD_API_KEY is not set. Get one at ${ATLAS_KEY_URL}`);
1165
1314
  }
1166
- return new AtlasCloudClient({ apiKey, baseUrl: resolveKey("ATLASCLOUD_BASE_URL") });
1315
+ return apiKey;
1316
+ }
1317
+ function createAtlasClientFromEnv() {
1318
+ const apiKey = requireAtlasKey();
1319
+ const baseUrl = resolveProviderBaseUrl(ATLAS_ROUTING);
1320
+ return new AtlasCloudClient({ apiKey, baseUrl });
1167
1321
  }
1168
1322
  async function resolveMediaRef(client, ref, logger) {
1169
1323
  const trimmed = ref.trim();
@@ -2346,6 +2500,11 @@ init_errors();
2346
2500
 
2347
2501
  // src/providers/byteplus/models.ts
2348
2502
  var DEFAULT_BYTEPLUS_BASE_URL = "https://ark.ap-southeast.bytepluses.com/api/v3";
2503
+ var BYTEPLUS_ROUTING = {
2504
+ name: "BYTEPLUS_BASE_URL",
2505
+ defaultUrl: DEFAULT_BYTEPLUS_BASE_URL,
2506
+ keyEnvs: ["BYTEPLUS_API_KEY", "ARK_API_KEY"]
2507
+ };
2349
2508
  var DEFAULT_BYTEPLUS_IMAGE_MODEL = "seedream-4-0-250828";
2350
2509
  var DEFAULT_BYTEPLUS_VIDEO_MODEL = "seedance-2.0";
2351
2510
  var DEFAULT_BYTEPLUS_3D_MODEL = "hyper3d-gen2-260112";
@@ -2369,7 +2528,7 @@ function bytePlusVideoParamsMode() {
2369
2528
 
2370
2529
  // src/providers/byteplus/client.ts
2371
2530
  function requireBytePlusKey() {
2372
- const key = resolveKey("BYTEPLUS_API_KEY") ?? resolveKey("ARK_API_KEY");
2531
+ const key = resolveRoutedKey(BYTEPLUS_ROUTING);
2373
2532
  if (!key) {
2374
2533
  throw new ConfigError(
2375
2534
  "BytePlus API key not set. Set BYTEPLUS_API_KEY or ARK_API_KEY. Get one at https://console.byteplus.com/auth/api-keys"
@@ -2378,7 +2537,7 @@ function requireBytePlusKey() {
2378
2537
  return key;
2379
2538
  }
2380
2539
  function bytePlusBaseUrl() {
2381
- return resolveKey("BYTEPLUS_BASE_URL") ?? DEFAULT_BYTEPLUS_BASE_URL;
2540
+ return resolveProviderBaseUrl(BYTEPLUS_ROUTING);
2382
2541
  }
2383
2542
  var BytePlusClient = class {
2384
2543
  constructor(apiKey, baseUrl = bytePlusBaseUrl()) {
@@ -3159,11 +3318,15 @@ var CLOUDFLARE_TTS_MODEL = "@cf/myshell-ai/melotts";
3159
3318
  var CLOUDFLARE_VIDEO_MODEL = "prunaai/p-video";
3160
3319
 
3161
3320
  // src/providers/cloudflare/models.ts
3162
- function required(name) {
3163
- const value = resolveKey(name)?.trim();
3164
- if (!value) throw new ConfigError(`${name} is required for Cloudflare media generation`);
3165
- return value;
3166
- }
3321
+ var CLOUDFLARE_ACCOUNT_ID_ENV = "CLOUDFLARE_ACCOUNT_ID";
3322
+ var CLOUDFLARE_GATEWAY_ID_ENV = "CLOUDFLARE_AI_GATEWAY_ID";
3323
+ var CLOUDFLARE_TOKEN_ENV = "CLOUDFLARE_API_TOKEN";
3324
+ var GATEWAY_REPLICATE_TOKEN_ENV = "REPLICATE_API_TOKEN";
3325
+ var CLOUDFLARE_NATIVE_CREDENTIALS = [CLOUDFLARE_TOKEN_ENV];
3326
+ var CLOUDFLARE_VIDEO_CREDENTIALS = [
3327
+ CLOUDFLARE_TOKEN_ENV,
3328
+ GATEWAY_REPLICATE_TOKEN_ENV
3329
+ ];
3167
3330
  function getCloudflareModel(kind, requested) {
3168
3331
  const expected = kind === "image" ? CLOUDFLARE_IMAGE_MODEL : CLOUDFLARE_TTS_MODEL;
3169
3332
  const overrideName = kind === "image" ? "CLOUDFLARE_AI_IMAGE_MODEL" : "CLOUDFLARE_AI_TTS_MODEL";
@@ -3183,22 +3346,57 @@ function validateImageSteps(value) {
3183
3346
  }
3184
3347
  return steps;
3185
3348
  }
3186
- function requireCloudflareConfig() {
3349
+ var requiredMessage = (name) => `${name} is required for Cloudflare media generation`;
3350
+ var token = (name) => resolveKey(name)?.trim() || void 0;
3351
+ function inspectCloudflareConfig(use = "native") {
3352
+ const credentials = use === "video" ? CLOUDFLARE_VIDEO_CREDENTIALS : CLOUDFLARE_NATIVE_CREDENTIALS;
3353
+ const accountId = inspectTrustedSetting(CLOUDFLARE_ACCOUNT_ID_ENV, credentials);
3354
+ const gatewayId = inspectTrustedSetting(CLOUDFLARE_GATEWAY_ID_ENV, credentials);
3355
+ const result = (problem, refused = false) => ({
3356
+ problem,
3357
+ refused,
3358
+ accountId,
3359
+ gatewayId
3360
+ });
3361
+ if (accountId.status === "refused") {
3362
+ return result(trustedSettingRefusal(CLOUDFLARE_ACCOUNT_ID_ENV, accountId), true);
3363
+ }
3364
+ if (accountId.status === "unset") return result(requiredMessage(CLOUDFLARE_ACCOUNT_ID_ENV));
3365
+ if (!token(CLOUDFLARE_TOKEN_ENV)) return result(requiredMessage(CLOUDFLARE_TOKEN_ENV));
3366
+ if (gatewayId.status === "refused") {
3367
+ return result(trustedSettingRefusal(CLOUDFLARE_GATEWAY_ID_ENV, gatewayId), true);
3368
+ }
3369
+ if (use === "video") {
3370
+ if (gatewayId.status === "unset") {
3371
+ return result(`${CLOUDFLARE_GATEWAY_ID_ENV} is required for Cloudflare video generation`);
3372
+ }
3373
+ if (!token(GATEWAY_REPLICATE_TOKEN_ENV)) {
3374
+ return result(requiredMessage(GATEWAY_REPLICATE_TOKEN_ENV));
3375
+ }
3376
+ }
3377
+ return result();
3378
+ }
3379
+ function requireConfig(use) {
3380
+ const check = inspectCloudflareConfig(use);
3381
+ if (check.problem) throw new ConfigError(check.problem);
3187
3382
  return {
3188
- accountId: required("CLOUDFLARE_ACCOUNT_ID"),
3189
- apiToken: required("CLOUDFLARE_API_TOKEN"),
3190
- gatewayId: resolveKey("CLOUDFLARE_AI_GATEWAY_ID")?.trim() || void 0
3383
+ // applyTrustedSetting prints the one-time warning when a ~/.multix/.env fallback is used.
3384
+ accountId: applyTrustedSetting(CLOUDFLARE_ACCOUNT_ID_ENV, check.accountId),
3385
+ apiToken: token(CLOUDFLARE_TOKEN_ENV),
3386
+ gatewayId: applyTrustedSetting(CLOUDFLARE_GATEWAY_ID_ENV, check.gatewayId),
3387
+ ...use === "video" ? { replicateApiToken: token(GATEWAY_REPLICATE_TOKEN_ENV) } : {}
3191
3388
  };
3192
3389
  }
3390
+ function requireCloudflareConfig() {
3391
+ return requireConfig("native");
3392
+ }
3193
3393
  function requireCloudflareVideoConfig() {
3194
- const config = requireCloudflareConfig();
3195
- if (!config.gatewayId) {
3196
- throw new ConfigError("CLOUDFLARE_AI_GATEWAY_ID is required for Cloudflare video generation");
3197
- }
3394
+ const config = requireConfig("video");
3198
3395
  return {
3199
- ...config,
3396
+ accountId: config.accountId,
3397
+ apiToken: config.apiToken,
3200
3398
  gatewayId: config.gatewayId,
3201
- replicateApiToken: required("REPLICATE_API_TOKEN")
3399
+ replicateApiToken: config.replicateApiToken
3202
3400
  };
3203
3401
  }
3204
3402
 
@@ -4307,6 +4505,11 @@ import path22 from "path";
4307
4505
 
4308
4506
  // src/providers/fal/models.ts
4309
4507
  var DEFAULT_FAL_BASE_URL = "https://queue.fal.run";
4508
+ var FAL_ROUTING = {
4509
+ name: "FAL_BASE_URL",
4510
+ defaultUrl: DEFAULT_FAL_BASE_URL,
4511
+ keyEnvs: ["FAL_KEY"]
4512
+ };
4310
4513
  var FAL_DEFAULTS = {
4311
4514
  // FLUX.1 [schnell] — fast, low-cost default text-to-image model.
4312
4515
  imageModel: process.env.FAL_IMAGE_MODEL ?? "fal-ai/flux/schnell",
@@ -4326,14 +4529,14 @@ init_errors();
4326
4529
  init_errors();
4327
4530
  init_redact();
4328
4531
  function requireFalKey() {
4329
- const key = resolveKey("FAL_KEY");
4532
+ const key = resolveRoutedKey(FAL_ROUTING);
4330
4533
  if (!key) {
4331
4534
  throw new ConfigError("FAL_KEY is not set. Get one at https://fal.ai/dashboard/keys");
4332
4535
  }
4333
4536
  return key;
4334
4537
  }
4335
4538
  function falBaseUrl() {
4336
- return resolveKey("FAL_BASE_URL") ?? DEFAULT_FAL_BASE_URL;
4539
+ return resolveProviderBaseUrl(FAL_ROUTING);
4337
4540
  }
4338
4541
  var FalClient = class {
4339
4542
  constructor(apiKey, baseUrl = falBaseUrl()) {
@@ -6291,6 +6494,11 @@ function registerGeminiCommands(program2) {
6291
6494
 
6292
6495
  // src/providers/heygen/models.ts
6293
6496
  var DEFAULT_HEYGEN_BASE_URL = "https://api.heygen.com";
6497
+ var HEYGEN_ROUTING = {
6498
+ name: "HEYGEN_BASE_URL",
6499
+ defaultUrl: DEFAULT_HEYGEN_BASE_URL,
6500
+ keyEnvs: ["HEYGEN_API_KEY"]
6501
+ };
6294
6502
  var HEYGEN_API_SETTINGS_URL = "https://app.heygen.com/developers/api";
6295
6503
  var HEYGEN_USAGE_URL = "https://app.heygen.com/developers/usage";
6296
6504
  var AVATAR_VIDEO_RESOLUTIONS = ["4k", "1080p", "720p"];
@@ -7312,7 +7520,7 @@ function qualityMode(value) {
7312
7520
 
7313
7521
  // src/providers/heygen/node-helpers.ts
7314
7522
  function requireHeygenKey() {
7315
- const key = resolveKey("HEYGEN_API_KEY")?.trim();
7523
+ const key = resolveRoutedKey(HEYGEN_ROUTING);
7316
7524
  if (!key) {
7317
7525
  throw new ConfigError(
7318
7526
  `HEYGEN_API_KEY is not set. Create an API key at ${HEYGEN_API_SETTINGS_URL} (API usage is billed separately from HeyGen web plans).`
@@ -7322,11 +7530,8 @@ function requireHeygenKey() {
7322
7530
  }
7323
7531
  function createHeygenClientFromEnv() {
7324
7532
  const apiKey = requireHeygenKey();
7325
- const override = resolveKey("HEYGEN_BASE_URL")?.trim();
7326
- return new HeygenClient({
7327
- apiKey,
7328
- baseUrl: override ? checkBaseUrl(override, "HEYGEN_BASE_URL") : DEFAULT_HEYGEN_BASE_URL
7329
- });
7533
+ const baseUrl = resolveProviderBaseUrl(HEYGEN_ROUTING);
7534
+ return new HeygenClient({ apiKey, baseUrl: checkBaseUrl(baseUrl, "HEYGEN_BASE_URL") });
7330
7535
  }
7331
7536
  async function uploadLocalFile(client, filePath, logger) {
7332
7537
  const abs = path32.resolve(filePath);
@@ -8186,6 +8391,11 @@ init_errors();
8186
8391
 
8187
8392
  // src/providers/leonardo/models.ts
8188
8393
  var DEFAULT_LEONARDO_BASE_URL = "https://cloud.leonardo.ai/api/rest/v1";
8394
+ var LEONARDO_ROUTING = {
8395
+ name: "LEONARDO_BASE_URL",
8396
+ defaultUrl: DEFAULT_LEONARDO_BASE_URL,
8397
+ keyEnvs: ["LEONARDO_API_KEY"]
8398
+ };
8189
8399
  var DEFAULT_LEONARDO_MODEL_ID = "7b592283-e8a7-4c5a-9ba6-d18c31f258b9";
8190
8400
  var LEONARDO_DEFAULTS = {
8191
8401
  modelId: process.env.LEONARDO_DEFAULT_MODEL ?? DEFAULT_LEONARDO_MODEL_ID,
@@ -8234,7 +8444,7 @@ function isGptImageModel(modelId) {
8234
8444
 
8235
8445
  // src/providers/leonardo/client.ts
8236
8446
  function requireLeonardoKey() {
8237
- const key = resolveKey("LEONARDO_API_KEY");
8447
+ const key = resolveRoutedKey(LEONARDO_ROUTING);
8238
8448
  if (!key) {
8239
8449
  throw new ConfigError(
8240
8450
  "LEONARDO_API_KEY is not set. Get one at https://app.leonardo.ai/settings/api-keys"
@@ -8243,7 +8453,7 @@ function requireLeonardoKey() {
8243
8453
  return key;
8244
8454
  }
8245
8455
  function leonardoBaseUrl() {
8246
- return resolveKey("LEONARDO_BASE_URL") ?? DEFAULT_LEONARDO_BASE_URL;
8456
+ return resolveProviderBaseUrl(LEONARDO_ROUTING);
8247
8457
  }
8248
8458
  var LeonardoClient = class {
8249
8459
  constructor(apiKey, baseUrl = leonardoBaseUrl()) {
@@ -10686,6 +10896,11 @@ function registerOpenRouterCommands(program2) {
10686
10896
  // src/providers/replicate/models.ts
10687
10897
  init_errors();
10688
10898
  var DEFAULT_REPLICATE_BASE_URL = "https://api.replicate.com/v1";
10899
+ var REPLICATE_ROUTING = {
10900
+ name: "REPLICATE_BASE_URL",
10901
+ defaultUrl: DEFAULT_REPLICATE_BASE_URL,
10902
+ keyEnvs: ["REPLICATE_API_TOKEN", "REPLICATE_API_KEY"]
10903
+ };
10689
10904
  var DEFAULT_REPLICATE_IMAGE_MODEL = "black-forest-labs/flux-schnell";
10690
10905
  var DEFAULT_REPLICATE_VIDEO_MODEL = "prunaai/p-video";
10691
10906
  var TERMINAL_STATUSES = [
@@ -11009,10 +11224,9 @@ var ReplicateClient = class {
11009
11224
  };
11010
11225
 
11011
11226
  // src/providers/replicate/node-client.ts
11012
- var REPLICATE_TOKEN_ENV = "REPLICATE_API_TOKEN";
11013
- var REPLICATE_TOKEN_FALLBACK_ENV = "REPLICATE_API_KEY";
11227
+ var [REPLICATE_TOKEN_ENV, REPLICATE_TOKEN_FALLBACK_ENV] = REPLICATE_ROUTING.keyEnvs;
11014
11228
  function resolveReplicateToken() {
11015
- const found = resolveFirstKey(REPLICATE_TOKEN_ENV, REPLICATE_TOKEN_FALLBACK_ENV);
11229
+ const found = resolveFirstKey(...REPLICATE_ROUTING.keyEnvs);
11016
11230
  return found ? { token: found.value, envUsed: found.envUsed } : void 0;
11017
11231
  }
11018
11232
  function requireReplicateToken() {
@@ -11025,7 +11239,7 @@ function requireReplicateToken() {
11025
11239
  return resolved.token;
11026
11240
  }
11027
11241
  function replicateBaseUrl() {
11028
- return resolveKey("REPLICATE_BASE_URL")?.trim() || DEFAULT_REPLICATE_BASE_URL;
11242
+ return resolveProviderBaseUrl(REPLICATE_ROUTING);
11029
11243
  }
11030
11244
  function replicateImageModel() {
11031
11245
  return resolveKey("REPLICATE_IMAGE_MODEL")?.trim() || DEFAULT_REPLICATE_IMAGE_MODEL;
@@ -11364,7 +11578,7 @@ async function saveOne(item, predictionId, index, count, opts) {
11364
11578
  fs44.writeFileSync(dest, bytes);
11365
11579
  return dest;
11366
11580
  }
11367
- const warned = opts.warnedHosts;
11581
+ const warned2 = opts.warnedHosts;
11368
11582
  try {
11369
11583
  const { dest } = await downloadToFile(item.url, {
11370
11584
  dest: (response) => {
@@ -11376,8 +11590,8 @@ async function saveOne(item, predictionId, index, count, opts) {
11376
11590
  fetch: opts.fetch,
11377
11591
  validateUrl: (url) => {
11378
11592
  const { host, trusted } = checkOutputUrl(url);
11379
- if (!trusted && !warned.has(host)) {
11380
- warned.add(host);
11593
+ if (!trusted && !warned2.has(host)) {
11594
+ warned2.add(host);
11381
11595
  opts.logger.warn(
11382
11596
  `Output ${item.path} is served from ${host}, not ${REPLICATE_DELIVERY_HOST}`
11383
11597
  );
@@ -11807,6 +12021,10 @@ var FFMPEG_HINT = "ffmpeg not found \u2014 media optimize/split/batch commands u
11807
12021
  var MAGICK_HINT = "ImageMagick (magick) not found \u2014 image optimization unavailable.\n Linux: sudo apt-get install imagemagick\n macOS: brew install imagemagick\n Windows: https://imagemagick.org/script/download.php";
11808
12022
 
11809
12023
  // src/commands/check.ts
12024
+ function keyNames(routing) {
12025
+ const [envPrimary, envFallback] = routing.keyEnvs;
12026
+ return envFallback ? { envPrimary, envFallback } : { envPrimary };
12027
+ }
11810
12028
  var PROVIDERS = [
11811
12029
  {
11812
12030
  name: "Gemini",
@@ -11834,14 +12052,13 @@ var PROVIDERS = [
11834
12052
  },
11835
12053
  {
11836
12054
  name: "Leonardo.Ai",
11837
- envPrimary: "LEONARDO_API_KEY",
12055
+ ...keyNames(LEONARDO_ROUTING),
11838
12056
  optional: true,
11839
12057
  link: "https://app.leonardo.ai/settings/api-keys"
11840
12058
  },
11841
12059
  {
11842
12060
  name: "BytePlus",
11843
- envPrimary: "BYTEPLUS_API_KEY",
11844
- envFallback: "ARK_API_KEY",
12061
+ ...keyNames(BYTEPLUS_ROUTING),
11845
12062
  optional: true,
11846
12063
  link: "https://console.byteplus.com/auth/api-keys"
11847
12064
  },
@@ -11853,26 +12070,25 @@ var PROVIDERS = [
11853
12070
  },
11854
12071
  {
11855
12072
  name: "fal.ai",
11856
- envPrimary: "FAL_KEY",
12073
+ ...keyNames(FAL_ROUTING),
11857
12074
  optional: true,
11858
12075
  link: "https://fal.ai/dashboard/keys"
11859
12076
  },
11860
12077
  {
11861
12078
  name: "Atlas Cloud",
11862
- envPrimary: "ATLASCLOUD_API_KEY",
12079
+ ...keyNames(ATLAS_ROUTING),
11863
12080
  optional: true,
11864
12081
  link: "https://www.atlascloud.ai/console/api-keys"
11865
12082
  },
11866
12083
  {
11867
12084
  name: "Replicate",
11868
- envPrimary: "REPLICATE_API_TOKEN",
11869
- envFallback: "REPLICATE_API_KEY",
12085
+ ...keyNames(REPLICATE_ROUTING),
11870
12086
  optional: true,
11871
12087
  link: "https://replicate.com/account/api-tokens"
11872
12088
  },
11873
12089
  {
11874
12090
  name: "HeyGen",
11875
- envPrimary: "HEYGEN_API_KEY",
12091
+ ...keyNames(HEYGEN_ROUTING),
11876
12092
  optional: true,
11877
12093
  link: "https://app.heygen.com/developers/api"
11878
12094
  }
@@ -11887,14 +12103,64 @@ function resolveProviderKey(p) {
11887
12103
  return found ? { key: found.value, envUsed: found.envUsed } : {};
11888
12104
  }
11889
12105
  function cloudflareReadiness() {
11890
- const accountId = resolveKey("CLOUDFLARE_ACCOUNT_ID")?.trim();
11891
- const apiToken = resolveKey("CLOUDFLARE_API_TOKEN")?.trim();
11892
- const gatewayId = resolveKey("CLOUDFLARE_AI_GATEWAY_ID")?.trim();
11893
- const replicateToken = resolveKey("REPLICATE_API_TOKEN")?.trim();
11894
- return {
11895
- native: Boolean(accountId && apiToken),
11896
- video: Boolean(accountId && apiToken && gatewayId && replicateToken)
12106
+ const nativeCheck = inspectCloudflareConfig("native");
12107
+ const videoCheck = inspectCloudflareConfig("video");
12108
+ return { native: !nativeCheck.problem, video: !videoCheck.problem, nativeCheck, videoCheck };
12109
+ }
12110
+ var BASE_URL_ROUTING = [
12111
+ ATLAS_ROUTING,
12112
+ BYTEPLUS_ROUTING,
12113
+ FAL_ROUTING,
12114
+ HEYGEN_ROUTING,
12115
+ LEONARDO_ROUTING,
12116
+ REPLICATE_ROUTING
12117
+ ];
12118
+ function usedFrom(state) {
12119
+ if (state.source === "project") {
12120
+ if (state.basis === "default") return "project .env; same as the default endpoint";
12121
+ if (state.basis === "opt-in") return `project .env; allowed by ${ALLOW_PROJECT_BASE_URL_ENV}`;
12122
+ return "project .env, which also holds every key sent with it";
12123
+ }
12124
+ if (state.source === "user") return "~/.multix/.env";
12125
+ if (state.source === "legacy") return "legacy .claude file";
12126
+ return "shell";
12127
+ }
12128
+ function settingLine(label, state, commands = "commands that use it") {
12129
+ const stops = `${commands} will stop with an error`;
12130
+ if (state.status === "unset") return void 0;
12131
+ if (state.status === "refused") {
12132
+ return {
12133
+ level: "warn",
12134
+ message: `${label} ignored: set in project .env while ${state.reason}; ${stops}`
12135
+ };
12136
+ }
12137
+ if (state.schemeError) return { level: "warn", message: `${state.schemeError}; ${stops}` };
12138
+ if (state.status === "used") {
12139
+ return { level: "info", message: `${label} in effect (from ${usedFrom(state)})` };
12140
+ }
12141
+ const why = state.reason === "it is empty" ? "set empty in project .env" : `set in project .env while ${state.reason}`;
12142
+ return { level: "warn", message: `${label} ignored: ${why}; using ~/.multix/.env` };
12143
+ }
12144
+ var sameState = (a, b) => JSON.stringify(a) === JSON.stringify(b);
12145
+ function routingSettingReport() {
12146
+ const lines = [];
12147
+ const push = (line) => {
12148
+ if (line) lines.push(line);
11897
12149
  };
12150
+ for (const routing of BASE_URL_ROUTING) {
12151
+ push(settingLine(routing.name, inspectProviderBaseUrl(routing)));
12152
+ }
12153
+ const { nativeCheck, videoCheck } = cloudflareReadiness();
12154
+ for (const [name, key] of [
12155
+ [CLOUDFLARE_ACCOUNT_ID_ENV, "accountId"],
12156
+ [CLOUDFLARE_GATEWAY_ID_ENV, "gatewayId"]
12157
+ ]) {
12158
+ push(settingLine(name, nativeCheck[key]));
12159
+ if (!sameState(nativeCheck[key], videoCheck[key])) {
12160
+ push(settingLine(`${name} (video)`, videoCheck[key], "video commands"));
12161
+ }
12162
+ }
12163
+ return lines;
11898
12164
  }
11899
12165
  async function checkCodexImageReadiness(hasAnyProviderKey, verbose = false, checker = checkCodexAuthenticated) {
11900
12166
  if (hasAnyProviderKey && !verbose) return false;
@@ -11958,18 +12224,31 @@ function registerCheckCommand(program2) {
11958
12224
  }
11959
12225
  }
11960
12226
  const cloudflare = cloudflareReadiness();
12227
+ const blocked = (what) => logger.warn(
12228
+ `${what} will stop: a project .env setting is not trusted (see Endpoint Overrides)`
12229
+ );
11961
12230
  if (cloudflare.native) {
11962
12231
  anyKey = true;
11963
12232
  logger.success("Cloudflare Workers AI credentials configured (image and speech)");
11964
12233
  if (cloudflare.video) {
11965
12234
  logger.success("Cloudflare AI Gateway video credentials configured");
12235
+ } else if (cloudflare.videoCheck.refused) {
12236
+ blocked("Cloudflare video");
11966
12237
  } else {
11967
- logger.info("Cloudflare video needs CLOUDFLARE_AI_GATEWAY_ID and REPLICATE_API_TOKEN");
12238
+ logger.info(`Cloudflare video unavailable: ${cloudflare.videoCheck.problem}`);
11968
12239
  }
12240
+ } else if (cloudflare.nativeCheck.refused) {
12241
+ blocked("Cloudflare image, speech and video");
11969
12242
  } else {
11970
- logger.info(
11971
- "Cloudflare media needs CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN (optional)"
11972
- );
12243
+ logger.info(`Cloudflare media unavailable (optional): ${cloudflare.nativeCheck.problem}`);
12244
+ }
12245
+ const routing = routingSettingReport();
12246
+ if (routing.length > 0) {
12247
+ logger.header("Endpoint Overrides");
12248
+ for (const line of routing) {
12249
+ if (line.level === "warn") logger.warn(line.message);
12250
+ else logger.info(line.message);
12251
+ }
11973
12252
  }
11974
12253
  const shouldCheckCodex = !anyKey || opts.verbose;
11975
12254
  let codexReady = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrgoonie/multix",
3
- "version": "0.15.3",
3
+ "version": "1.0.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": {
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.15.3 # x-release-please-version
4
+ version: 1.0.0 # x-release-please-version
5
5
  ---
6
6
 
7
7
  # multix — AI Multimodal CLI
@@ -414,6 +414,7 @@ Requires `cwebp` (libwebp: `brew install webp`, `apt install webp`) on `PATH`.
414
414
  | `CLOUDFLARE_AI_IMAGE_MODEL` / `CLOUDFLARE_AI_TTS_MODEL` | Fixed-model settings: only `@cf/black-forest-labs/flux-1-schnell` and `@cf/myshell-ai/melotts` respectively are accepted |
415
415
  | `MULTIX_DISABLE_HOME_ENV` | `1` to skip every home-directory env file (`~/.multix/.env`, `~/.claude/...`) |
416
416
  | `MULTIX_LEGACY_CLAUDE_ENV` | `1` to also read legacy `.claude` env files (credential names only) |
417
+ | `MULTIX_ALLOW_PROJECT_BASE_URL` | Set `1` for one command (`MULTIX_ALLOW_PROJECT_BASE_URL=1 multix ...`) to honour `*_BASE_URL`, `CLOUDFLARE_ACCOUNT_ID` and `CLOUDFLARE_AI_GATEWAY_ID` from the project `.env` even when the key comes from elsewhere. Without it, a project value is used only when every key sent with it is in the same project file (a project `.env` that ships its own key and base URL then receives that project's prompts and uploaded files, and its key replaces yours, so review a cloned repo's `.env` first); otherwise the `~/.multix/.env` value is used with a warning, or the command stops with a config error. `multix check` shows the effective state. Ignored in a project `.env`; avoid `~/.multix/.env` (it trusts every directory). Non-default base URLs must be `https://` (http only for localhost/127.0.0.1/[::1]) |
417
418
 
418
419
  ## Quick examples for agents
419
420