@mrgoonie/multix 0.15.2 → 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,24 @@ 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
+
19
+ ## [0.15.3](https://github.com/mrgoonie/multix-cli/compare/v0.15.2...v0.15.3) (2026-10-11)
20
+
21
+
22
+ ### Fixed
23
+
24
+ * **core:** never forward API-key headers across origins on redirect ([#101](https://github.com/mrgoonie/multix-cli/issues/101)) ([5fd673d](https://github.com/mrgoonie/multix-cli/commit/5fd673ddac9e30b592348cafd6ab68343fa644d7))
25
+
8
26
  ## [0.15.2](https://github.com/mrgoonie/multix-cli/compare/v0.15.1...v0.15.2) (2026-10-11)
9
27
 
10
28
 
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,36 +393,174 @@ 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)}`;
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)";
413
517
  }
414
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;
563
+ }
415
564
  function getLoadedEnvFiles() {
416
565
  return [...loadedFiles];
417
566
  }
@@ -541,6 +690,113 @@ init_errors();
541
690
 
542
691
  // src/core/fetch-json.ts
543
692
  init_errors();
693
+
694
+ // src/core/redirect-fetch.ts
695
+ init_errors();
696
+ var MAX_REDIRECTS = 5;
697
+ var REDIRECT_STATUSES = /* @__PURE__ */ new Set([301, 302, 303, 307, 308]);
698
+ var BODY_HEADERS = ["content-type", "content-length", "content-encoding", "content-language"];
699
+ function withoutBodyHeaders(headers2) {
700
+ if (headers2 === void 0) return void 0;
701
+ const copy = new Headers(headers2);
702
+ for (const name of BODY_HEADERS) copy.delete(name);
703
+ return copy;
704
+ }
705
+ async function fetchFollowingRedirects(url, init = {}, opts = {}) {
706
+ const doFetch = opts.fetch ?? ((input, i) => globalThis.fetch(input, i));
707
+ const { validateUrl } = opts;
708
+ const maxRedirects = opts.maxRedirects ?? MAX_REDIRECTS;
709
+ let current = url;
710
+ let hopInit = { ...init, redirect: "manual" };
711
+ for (let hop = 0; ; hop++) {
712
+ await validateUrl?.(current);
713
+ let response;
714
+ try {
715
+ response = await doFetch(current, hopInit);
716
+ } catch (cause) {
717
+ if (!opts.wrapNetworkErrors) throw cause;
718
+ const msg = cause instanceof Error ? cause.message : String(cause);
719
+ throw new HttpError(0, msg, current);
720
+ }
721
+ if (validateUrl && response.url && response.url !== current) {
722
+ try {
723
+ await validateUrl(response.url);
724
+ } catch (err2) {
725
+ await response.body?.cancel().catch(() => {
726
+ });
727
+ throw err2;
728
+ }
729
+ }
730
+ const location = response.headers.get("location");
731
+ if (!REDIRECT_STATUSES.has(response.status) || !location) {
732
+ return { response, url: current };
733
+ }
734
+ let next;
735
+ try {
736
+ next = new URL(location, current);
737
+ } catch {
738
+ }
739
+ const crossOrigin = next !== void 0 && next.origin !== new URL(current).origin;
740
+ if (crossOrigin && opts.crossOrigin === "stop") return { response, url: current };
741
+ await response.body?.cancel().catch(() => {
742
+ });
743
+ if (hop >= maxRedirects) {
744
+ throw new HttpError(response.status, `too many redirects (over ${maxRedirects})`, current);
745
+ }
746
+ if (!next) {
747
+ throw new HttpError(response.status, "redirect with a malformed Location header", current);
748
+ }
749
+ if (crossOrigin) {
750
+ if (opts.crossOrigin === "error") {
751
+ throw new HttpError(
752
+ response.status,
753
+ `refusing to follow a redirect to another origin (${next.origin})`,
754
+ current
755
+ );
756
+ }
757
+ hopInit = { ...hopInit, headers: void 0 };
758
+ }
759
+ const method = (hopInit.method ?? "GET").toUpperCase();
760
+ const toGet = response.status === 303 && method !== "GET" && method !== "HEAD" || (response.status === 301 || response.status === 302) && method === "POST";
761
+ if (toGet) {
762
+ hopInit = {
763
+ ...hopInit,
764
+ method: "GET",
765
+ body: void 0,
766
+ headers: withoutBodyHeaders(hopInit.headers)
767
+ };
768
+ }
769
+ current = next.toString();
770
+ }
771
+ }
772
+ async function fetchSameOrigin(url, init = {}, fetchImpl) {
773
+ const { response } = await fetchFollowingRedirects(url, init, {
774
+ fetch: fetchImpl,
775
+ crossOrigin: "stop"
776
+ });
777
+ return response;
778
+ }
779
+ async function fetchWithValidatedRedirects(url, opts = {}) {
780
+ const { signal, headers: headers2, validateUrl } = opts;
781
+ const init = { ...signal ? { signal } : {}, ...headers2 ? { headers: headers2 } : {} };
782
+ if (!validateUrl && !headers2) {
783
+ const doFetch = opts.fetch ?? ((input, i) => globalThis.fetch(input, i));
784
+ try {
785
+ return { response: await doFetch(url, init), url };
786
+ } catch (cause) {
787
+ const msg = cause instanceof Error ? cause.message : String(cause);
788
+ throw new HttpError(0, msg, url);
789
+ }
790
+ }
791
+ return fetchFollowingRedirects(url, init, {
792
+ validateUrl,
793
+ maxRedirects: opts.maxRedirects,
794
+ fetch: opts.fetch,
795
+ wrapNetworkErrors: true
796
+ });
797
+ }
798
+
799
+ // src/core/fetch-json.ts
544
800
  var DEFAULT_TIMEOUT_MS = 12e4;
545
801
  var SNIPPET_MAX = 500;
546
802
  function parseRetryAfter(value, now = Date.now()) {
@@ -566,6 +822,10 @@ async function httpErrorFromResponse(response, url) {
566
822
  parseRetryAfter(response.headers.get("retry-after"))
567
823
  );
568
824
  }
825
+ var NON_CREDENTIAL_HEADERS = /* @__PURE__ */ new Set(["accept", "accept-language", "content-type", "user-agent"]);
826
+ function carriesCredentialHeaders(headers2) {
827
+ return Object.keys(headers2).some((name) => !NON_CREDENTIAL_HEADERS.has(name.toLowerCase()));
828
+ }
569
829
  async function fetchJson(opts) {
570
830
  const { url, method = "GET", headers: headers2 = {}, body, timeoutMs = DEFAULT_TIMEOUT_MS } = opts;
571
831
  const doFetch = opts.fetch ?? ((input, init2) => globalThis.fetch(input, init2));
@@ -580,13 +840,11 @@ async function fetchJson(opts) {
580
840
  if (body !== void 0) {
581
841
  init.body = JSON.stringify(body);
582
842
  }
583
- let response;
584
- try {
585
- response = await doFetch(url, init);
586
- } catch (cause) {
587
- const msg = cause instanceof Error ? cause.message : String(cause);
588
- throw new HttpError(0, msg, url);
589
- }
843
+ const { response } = await fetchFollowingRedirects(url, init, {
844
+ fetch: doFetch,
845
+ crossOrigin: carriesCredentialHeaders(headers2) ? "error" : "drop-headers",
846
+ wrapNetworkErrors: true
847
+ });
590
848
  if (!response.ok) {
591
849
  throw await httpErrorFromResponse(response, url);
592
850
  }
@@ -934,13 +1192,18 @@ var AtlasCloudClient = class {
934
1192
  const url = `${this.baseUrl}/model/uploadMedia`;
935
1193
  let response;
936
1194
  try {
937
- response = await this.doFetch(url, {
938
- method: "POST",
939
- headers: this.authHeaders(),
940
- body: form,
941
- signal: AbortSignal.timeout(this.uploadTimeoutMs)
942
- });
1195
+ response = await fetchSameOrigin(
1196
+ url,
1197
+ {
1198
+ method: "POST",
1199
+ headers: this.authHeaders(),
1200
+ body: form,
1201
+ signal: AbortSignal.timeout(this.uploadTimeoutMs)
1202
+ },
1203
+ (input, init) => this.doFetch(input, init)
1204
+ );
943
1205
  } catch (cause) {
1206
+ if (cause instanceof HttpError) throw cause;
944
1207
  throw new HttpError(0, cause instanceof Error ? cause.message : String(cause), url);
945
1208
  }
946
1209
  if (!response.ok) throw await httpErrorFromResponse(response, url);
@@ -1044,12 +1307,17 @@ var MODEL_ENV = {
1044
1307
  function atlasDefaultModel(key) {
1045
1308
  return resolveKey(MODEL_ENV[key])?.trim() || ATLAS_DEFAULT_MODELS[key];
1046
1309
  }
1047
- function createAtlasClientFromEnv() {
1048
- const apiKey = resolveKey("ATLASCLOUD_API_KEY")?.trim();
1310
+ function requireAtlasKey() {
1311
+ const apiKey = resolveRoutedKey(ATLAS_ROUTING);
1049
1312
  if (!apiKey) {
1050
1313
  throw new ConfigError(`ATLASCLOUD_API_KEY is not set. Get one at ${ATLAS_KEY_URL}`);
1051
1314
  }
1052
- 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 });
1053
1321
  }
1054
1322
  async function resolveMediaRef(client, ref, logger) {
1055
1323
  const trimmed = ref.trim();
@@ -1133,7 +1401,6 @@ async function httpJson(opts) {
1133
1401
  }
1134
1402
  var MAX_DOWNLOAD_ENV = "MULTIX_MAX_DOWNLOAD_MB";
1135
1403
  var DEFAULT_MAX_DOWNLOAD_MB = 2048;
1136
- var MAX_REDIRECTS = 5;
1137
1404
  function formatMb2(bytes) {
1138
1405
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
1139
1406
  }
@@ -1163,44 +1430,6 @@ function maxDownloadBytes() {
1163
1430
  }
1164
1431
  return bytes;
1165
1432
  }
1166
- async function fetchWithValidatedRedirects(url, opts = {}) {
1167
- const doFetch = opts.fetch ?? ((input, init) => globalThis.fetch(input, init));
1168
- const { validateUrl, signal } = opts;
1169
- const maxRedirects = opts.maxRedirects ?? MAX_REDIRECTS;
1170
- let current = url;
1171
- let headers2 = opts.headers;
1172
- for (let hop = 0; ; hop++) {
1173
- await validateUrl?.(current);
1174
- let response;
1175
- try {
1176
- response = await doFetch(current, {
1177
- ...signal ? { signal } : {},
1178
- ...headers2 ? { headers: headers2 } : {},
1179
- ...validateUrl ? { redirect: "manual" } : {}
1180
- });
1181
- } catch (cause) {
1182
- const msg = cause instanceof Error ? cause.message : String(cause);
1183
- throw new HttpError(0, msg, current);
1184
- }
1185
- const location = response.headers.get("location");
1186
- if (!validateUrl || !location || response.status < 300 || response.status >= 400) {
1187
- return { response, url: current };
1188
- }
1189
- await response.body?.cancel().catch(() => {
1190
- });
1191
- if (hop >= maxRedirects) {
1192
- throw new HttpError(response.status, `too many redirects (over ${maxRedirects})`, current);
1193
- }
1194
- let next;
1195
- try {
1196
- next = new URL(location, current);
1197
- } catch {
1198
- throw new HttpError(response.status, "redirect with a malformed Location header", current);
1199
- }
1200
- if (next.origin !== new URL(current).origin) headers2 = void 0;
1201
- current = next.toString();
1202
- }
1203
- }
1204
1433
  function removeQuietly(file) {
1205
1434
  try {
1206
1435
  fs3.rmSync(file, { force: true });
@@ -1262,7 +1491,7 @@ async function downloadToFile(url, opts) {
1262
1491
  yield chunk;
1263
1492
  }
1264
1493
  }
1265
- const part = `${dest}.part`;
1494
+ const part = `${dest}.${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.part`;
1266
1495
  let fileStream;
1267
1496
  try {
1268
1497
  fileStream = fs3.createWriteStream(part);
@@ -2271,6 +2500,11 @@ init_errors();
2271
2500
 
2272
2501
  // src/providers/byteplus/models.ts
2273
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
+ };
2274
2508
  var DEFAULT_BYTEPLUS_IMAGE_MODEL = "seedream-4-0-250828";
2275
2509
  var DEFAULT_BYTEPLUS_VIDEO_MODEL = "seedance-2.0";
2276
2510
  var DEFAULT_BYTEPLUS_3D_MODEL = "hyper3d-gen2-260112";
@@ -2294,7 +2528,7 @@ function bytePlusVideoParamsMode() {
2294
2528
 
2295
2529
  // src/providers/byteplus/client.ts
2296
2530
  function requireBytePlusKey() {
2297
- const key = resolveKey("BYTEPLUS_API_KEY") ?? resolveKey("ARK_API_KEY");
2531
+ const key = resolveRoutedKey(BYTEPLUS_ROUTING);
2298
2532
  if (!key) {
2299
2533
  throw new ConfigError(
2300
2534
  "BytePlus API key not set. Set BYTEPLUS_API_KEY or ARK_API_KEY. Get one at https://console.byteplus.com/auth/api-keys"
@@ -2303,7 +2537,7 @@ function requireBytePlusKey() {
2303
2537
  return key;
2304
2538
  }
2305
2539
  function bytePlusBaseUrl() {
2306
- return resolveKey("BYTEPLUS_BASE_URL") ?? DEFAULT_BYTEPLUS_BASE_URL;
2540
+ return resolveProviderBaseUrl(BYTEPLUS_ROUTING);
2307
2541
  }
2308
2542
  var BytePlusClient = class {
2309
2543
  constructor(apiKey, baseUrl = bytePlusBaseUrl()) {
@@ -3084,11 +3318,15 @@ var CLOUDFLARE_TTS_MODEL = "@cf/myshell-ai/melotts";
3084
3318
  var CLOUDFLARE_VIDEO_MODEL = "prunaai/p-video";
3085
3319
 
3086
3320
  // src/providers/cloudflare/models.ts
3087
- function required(name) {
3088
- const value = resolveKey(name)?.trim();
3089
- if (!value) throw new ConfigError(`${name} is required for Cloudflare media generation`);
3090
- return value;
3091
- }
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
+ ];
3092
3330
  function getCloudflareModel(kind, requested) {
3093
3331
  const expected = kind === "image" ? CLOUDFLARE_IMAGE_MODEL : CLOUDFLARE_TTS_MODEL;
3094
3332
  const overrideName = kind === "image" ? "CLOUDFLARE_AI_IMAGE_MODEL" : "CLOUDFLARE_AI_TTS_MODEL";
@@ -3108,22 +3346,57 @@ function validateImageSteps(value) {
3108
3346
  }
3109
3347
  return steps;
3110
3348
  }
3111
- 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);
3112
3382
  return {
3113
- accountId: required("CLOUDFLARE_ACCOUNT_ID"),
3114
- apiToken: required("CLOUDFLARE_API_TOKEN"),
3115
- 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) } : {}
3116
3388
  };
3117
3389
  }
3390
+ function requireCloudflareConfig() {
3391
+ return requireConfig("native");
3392
+ }
3118
3393
  function requireCloudflareVideoConfig() {
3119
- const config = requireCloudflareConfig();
3120
- if (!config.gatewayId) {
3121
- throw new ConfigError("CLOUDFLARE_AI_GATEWAY_ID is required for Cloudflare video generation");
3122
- }
3394
+ const config = requireConfig("video");
3123
3395
  return {
3124
- ...config,
3396
+ accountId: config.accountId,
3397
+ apiToken: config.apiToken,
3125
3398
  gatewayId: config.gatewayId,
3126
- replicateApiToken: required("REPLICATE_API_TOKEN")
3399
+ replicateApiToken: config.replicateApiToken
3127
3400
  };
3128
3401
  }
3129
3402
 
@@ -3132,8 +3405,13 @@ init_errors();
3132
3405
  function cloudflareProviderFailure(status) {
3133
3406
  return new ProviderError(`Cloudflare request failed with HTTP ${status}`, "Cloudflare");
3134
3407
  }
3408
+ async function cloudflareResponseFailure(response) {
3409
+ await response.body?.cancel().catch(() => {
3410
+ });
3411
+ return cloudflareProviderFailure(response.status);
3412
+ }
3135
3413
  async function readCloudflareJson(response) {
3136
- if (!response.ok) throw cloudflareProviderFailure(response.status);
3414
+ if (!response.ok) throw await cloudflareResponseFailure(response);
3137
3415
  try {
3138
3416
  return await response.json();
3139
3417
  } catch {
@@ -3153,12 +3431,11 @@ function videoHeaders(config, opts) {
3153
3431
  };
3154
3432
  }
3155
3433
  function send(opts, url, init) {
3156
- const f = opts.fetch;
3157
3434
  const withTimeout = {
3158
3435
  ...init,
3159
3436
  signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS)
3160
3437
  };
3161
- return f ? f(url, withTimeout) : globalThis.fetch(url, withTimeout);
3438
+ return fetchSameOrigin(url, withTimeout, opts.fetch);
3162
3439
  }
3163
3440
  function parseCloudflareVideoPrediction(value) {
3164
3441
  if (!value || typeof value !== "object") {
@@ -3211,7 +3488,7 @@ function gatewayUrl(accountId) {
3211
3488
  async function runCloudflareJson(model, input) {
3212
3489
  const config = requireCloudflareConfig();
3213
3490
  const useGateway = Boolean(config.gatewayId);
3214
- const response = await globalThis.fetch(
3491
+ const response = await fetchSameOrigin(
3215
3492
  useGateway ? gatewayUrl(config.accountId) : nativeUrl(config.accountId, model),
3216
3493
  {
3217
3494
  method: "POST",
@@ -3228,7 +3505,7 @@ async function runCloudflareJson(model, input) {
3228
3505
  async function runCloudflareBytes(model, input) {
3229
3506
  const config = requireCloudflareConfig();
3230
3507
  const useGateway = Boolean(config.gatewayId);
3231
- const response = await globalThis.fetch(
3508
+ const response = await fetchSameOrigin(
3232
3509
  useGateway ? gatewayUrl(config.accountId) : nativeUrl(config.accountId, model),
3233
3510
  {
3234
3511
  method: "POST",
@@ -3236,7 +3513,7 @@ async function runCloudflareBytes(model, input) {
3236
3513
  body: JSON.stringify(useGateway ? { model, input } : input)
3237
3514
  }
3238
3515
  );
3239
- if (!response.ok) throw cloudflareProviderFailure(response.status);
3516
+ if (!response.ok) throw await cloudflareResponseFailure(response);
3240
3517
  const contentType = response.headers.get("content-type") ?? "";
3241
3518
  if (!contentType.includes("audio/mpeg")) {
3242
3519
  throw new ProviderError("Cloudflare did not return MPEG audio", "Cloudflare");
@@ -3558,7 +3835,7 @@ function apiPostJson(endpoint2, payload, apiKey, opts = {}) {
3558
3835
  }
3559
3836
  async function apiDelete(endpoint2, apiKey) {
3560
3837
  const url = `${ELEVENLABS_BASE_URL}/${endpoint2.replace(/^\//, "")}`;
3561
- const res = await globalThis.fetch(url, {
3838
+ const res = await fetchSameOrigin(url, {
3562
3839
  method: "DELETE",
3563
3840
  headers: authHeaders(apiKey),
3564
3841
  signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS2)
@@ -3592,8 +3869,9 @@ async function apiPostBinary(endpoint2, body, apiKey, opts = {}) {
3592
3869
  }
3593
3870
  let res;
3594
3871
  try {
3595
- res = await globalThis.fetch(url.toString(), init);
3872
+ res = await fetchSameOrigin(url.toString(), init);
3596
3873
  } catch (cause) {
3874
+ if (cause instanceof HttpError) throw cause;
3597
3875
  const msg = cause instanceof Error ? cause.message : String(cause);
3598
3876
  throw new HttpError(0, msg, url.toString());
3599
3877
  }
@@ -3607,13 +3885,14 @@ async function apiPostMultipart(endpoint2, form, apiKey, opts = {}) {
3607
3885
  const url = `${ELEVENLABS_BASE_URL}/${endpoint2.replace(/^\//, "")}`;
3608
3886
  let res;
3609
3887
  try {
3610
- res = await globalThis.fetch(url, {
3888
+ res = await fetchSameOrigin(url, {
3611
3889
  method: "POST",
3612
3890
  headers: authHeaders(apiKey),
3613
3891
  body: form,
3614
3892
  signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS2)
3615
3893
  });
3616
3894
  } catch (cause) {
3895
+ if (cause instanceof HttpError) throw cause;
3617
3896
  const msg = cause instanceof Error ? cause.message : String(cause);
3618
3897
  throw new HttpError(0, msg, url);
3619
3898
  }
@@ -3822,7 +4101,7 @@ Dubbed file: ${dest}`);
3822
4101
  }
3823
4102
  async function downloadDub(url, apiKey, dest) {
3824
4103
  fs15.mkdirSync(path18.dirname(dest), { recursive: true });
3825
- const res = await globalThis.fetch(url, {
4104
+ const { response: res } = await fetchWithValidatedRedirects(url, {
3826
4105
  headers: { "xi-api-key": apiKey },
3827
4106
  signal: AbortSignal.timeout(6e5)
3828
4107
  });
@@ -3831,7 +4110,6 @@ async function downloadDub(url, apiKey, dest) {
3831
4110
  }
3832
4111
  const arr = new Uint8Array(await res.arrayBuffer());
3833
4112
  fs15.writeFileSync(dest, arr);
3834
- void downloadFile;
3835
4113
  }
3836
4114
 
3837
4115
  // src/providers/elevenlabs/commands/isolate.ts
@@ -4227,6 +4505,11 @@ import path22 from "path";
4227
4505
 
4228
4506
  // src/providers/fal/models.ts
4229
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
+ };
4230
4513
  var FAL_DEFAULTS = {
4231
4514
  // FLUX.1 [schnell] — fast, low-cost default text-to-image model.
4232
4515
  imageModel: process.env.FAL_IMAGE_MODEL ?? "fal-ai/flux/schnell",
@@ -4246,14 +4529,14 @@ init_errors();
4246
4529
  init_errors();
4247
4530
  init_redact();
4248
4531
  function requireFalKey() {
4249
- const key = resolveKey("FAL_KEY");
4532
+ const key = resolveRoutedKey(FAL_ROUTING);
4250
4533
  if (!key) {
4251
4534
  throw new ConfigError("FAL_KEY is not set. Get one at https://fal.ai/dashboard/keys");
4252
4535
  }
4253
4536
  return key;
4254
4537
  }
4255
4538
  function falBaseUrl() {
4256
- return resolveKey("FAL_BASE_URL") ?? DEFAULT_FAL_BASE_URL;
4539
+ return resolveProviderBaseUrl(FAL_ROUTING);
4257
4540
  }
4258
4541
  var FalClient = class {
4259
4542
  constructor(apiKey, baseUrl = falBaseUrl()) {
@@ -5814,13 +6097,14 @@ function checkVeoResult(op) {
5814
6097
  return op;
5815
6098
  }
5816
6099
  async function downloadVeoVideo(apiKey, uri, dest) {
5817
- let host = "";
6100
+ let parsed;
5818
6101
  try {
5819
- host = new URL(uri).hostname;
6102
+ parsed = new URL(uri);
5820
6103
  } catch {
5821
6104
  throw new ProviderError("Veo returned an invalid video URI", "gemini");
5822
6105
  }
5823
- await downloadFile(uri, dest, 3e5, host === API_HOST ? headers(apiKey) : void 0);
6106
+ const keyed = parsed.protocol === "https:" && parsed.hostname === API_HOST;
6107
+ await downloadFile(uri, dest, 3e5, keyed ? headers(apiKey) : void 0);
5824
6108
  }
5825
6109
  async function runVeo(model, request, dest, logger) {
5826
6110
  const body = buildVeoRequest(request);
@@ -6210,6 +6494,11 @@ function registerGeminiCommands(program2) {
6210
6494
 
6211
6495
  // src/providers/heygen/models.ts
6212
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
+ };
6213
6502
  var HEYGEN_API_SETTINGS_URL = "https://app.heygen.com/developers/api";
6214
6503
  var HEYGEN_USAGE_URL = "https://app.heygen.com/developers/usage";
6215
6504
  var AVATAR_VIDEO_RESOLUTIONS = ["4k", "1080p", "720p"];
@@ -6316,10 +6605,8 @@ var HEYGEN_OPERATIONS = {
6316
6605
  };
6317
6606
 
6318
6607
  // src/providers/heygen/node-helpers.ts
6319
- import { once } from "events";
6320
6608
  import fs29 from "fs";
6321
6609
  import path32 from "path";
6322
- import { pipeline as pipeline2 } from "stream/promises";
6323
6610
  init_errors();
6324
6611
  init_redact();
6325
6612
 
@@ -6666,13 +6953,18 @@ var HeygenClient = class {
6666
6953
  form.append("file", typed, input.filename);
6667
6954
  let response;
6668
6955
  try {
6669
- response = await doFetch(url, {
6670
- method: "POST",
6671
- headers: this.headers(key),
6672
- body: form,
6673
- signal: AbortSignal.timeout(this.timeoutMs)
6674
- });
6956
+ response = await fetchSameOrigin(
6957
+ url,
6958
+ {
6959
+ method: "POST",
6960
+ headers: this.headers(key),
6961
+ body: form,
6962
+ signal: AbortSignal.timeout(this.timeoutMs)
6963
+ },
6964
+ doFetch
6965
+ );
6675
6966
  } catch (cause) {
6967
+ if (cause instanceof HttpError) throw toHeygenApiError(cause);
6676
6968
  const msg = cause instanceof Error ? cause.message : String(cause);
6677
6969
  const err3 = toHeygenApiError(new HttpError(0, msg, url));
6678
6970
  if (attempt >= this.maxAttempts) throw err3;
@@ -7228,7 +7520,7 @@ function qualityMode(value) {
7228
7520
 
7229
7521
  // src/providers/heygen/node-helpers.ts
7230
7522
  function requireHeygenKey() {
7231
- const key = resolveKey("HEYGEN_API_KEY")?.trim();
7523
+ const key = resolveRoutedKey(HEYGEN_ROUTING);
7232
7524
  if (!key) {
7233
7525
  throw new ConfigError(
7234
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).`
@@ -7238,11 +7530,8 @@ function requireHeygenKey() {
7238
7530
  }
7239
7531
  function createHeygenClientFromEnv() {
7240
7532
  const apiKey = requireHeygenKey();
7241
- const override = resolveKey("HEYGEN_BASE_URL")?.trim();
7242
- return new HeygenClient({
7243
- apiKey,
7244
- baseUrl: override ? checkBaseUrl(override, "HEYGEN_BASE_URL") : DEFAULT_HEYGEN_BASE_URL
7245
- });
7533
+ const baseUrl = resolveProviderBaseUrl(HEYGEN_ROUTING);
7534
+ return new HeygenClient({ apiKey, baseUrl: checkBaseUrl(baseUrl, "HEYGEN_BASE_URL") });
7246
7535
  }
7247
7536
  async function uploadLocalFile(client, filePath, logger) {
7248
7537
  const abs = path32.resolve(filePath);
@@ -7323,44 +7612,11 @@ function inferExtension(url, fallback) {
7323
7612
  function safeSlug(value) {
7324
7613
  return value.replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "out";
7325
7614
  }
7326
- var MAX_REDIRECTS2 = 5;
7327
- var isRedirect = (status) => [301, 302, 303, 307, 308].includes(status);
7328
- async function downloadHttpsOnly(url, dest, timeoutMs) {
7329
- const signal = AbortSignal.timeout(timeoutMs);
7330
- let current = url;
7331
- for (let hop = 0; ; hop++) {
7332
- if (!isHttpsUrl(current)) {
7333
- throw new HttpError(0, "refusing to follow a redirect to a non-https URL", current);
7334
- }
7335
- let response;
7336
- try {
7337
- response = await globalThis.fetch(current, { signal, redirect: "manual" });
7338
- } catch (cause) {
7339
- throw new HttpError(0, cause instanceof Error ? cause.message : String(cause), current);
7340
- }
7341
- if (response.url && !isHttpsUrl(response.url)) {
7342
- await response.body?.cancel();
7343
- throw new HttpError(0, "redirected to a non-https URL", response.url);
7344
- }
7345
- const location = response.headers.get("location");
7346
- if (isRedirect(response.status) && location) {
7347
- await response.body?.cancel();
7348
- if (hop >= MAX_REDIRECTS2) throw new HttpError(0, "too many redirects", url);
7349
- current = new URL(location, current).href;
7350
- continue;
7351
- }
7352
- if (!response.ok) throw new HttpError(response.status, "(download failed)", current);
7353
- if (!response.body) throw new HttpError(0, "Response body is null", current);
7354
- fs29.mkdirSync(path32.dirname(dest), { recursive: true });
7355
- const fileStream = fs29.createWriteStream(dest);
7356
- try {
7357
- await pipeline2(response.body, fileStream);
7358
- } catch (err2) {
7359
- if (!fileStream.closed) await once(fileStream, "close");
7360
- throw err2;
7361
- }
7362
- return;
7363
- }
7615
+ function requireHttps2(url) {
7616
+ if (!isHttpsUrl(url)) throw new HttpError(0, "redirected to a non-https URL", url);
7617
+ }
7618
+ function downloadHttps(url, dest, timeoutMs) {
7619
+ return downloadToFile(url, { dest, timeoutMs, validateUrl: requireHttps2 });
7364
7620
  }
7365
7621
  async function downloadOutput(url, dest, resumeHint) {
7366
7622
  if (!url) {
@@ -7371,12 +7627,9 @@ async function downloadOutput(url, dest, resumeHint) {
7371
7627
  `Refusing to download HeyGen output from a non-https URL (${redactUrl(url)})`
7372
7628
  );
7373
7629
  }
7374
- const tmp = `${dest}.${process.pid}-${Date.now()}.part`;
7375
7630
  try {
7376
- await downloadHttpsOnly(url, tmp, 6e5);
7377
- fs29.renameSync(tmp, dest);
7631
+ await downloadHttps(url, dest, 6e5);
7378
7632
  } catch (e) {
7379
- fs29.rmSync(tmp, { force: true });
7380
7633
  const status = e instanceof HttpError ? e.status : 0;
7381
7634
  const expired = status === 403 || status === 404 || status === 410;
7382
7635
  const reason = e instanceof Error ? e.message : String(e);
@@ -7438,7 +7691,7 @@ function captionPathFor(dest, capExt) {
7438
7691
  const capDest = `${stem}.${capExt}`;
7439
7692
  return capDest.toLowerCase() === dest.toLowerCase() ? `${stem}.captions.${capExt}` : capDest;
7440
7693
  }
7441
- var downloadThumbHttpsOnly = (url, dest) => downloadHttpsOnly(url, dest, 12e4);
7694
+ var downloadThumbHttpsOnly = (url, dest) => downloadHttps(url, dest, 12e4);
7442
7695
  function summarizeState(state) {
7443
7696
  const out = {
7444
7697
  kind: state.kind,
@@ -8138,6 +8391,11 @@ init_errors();
8138
8391
 
8139
8392
  // src/providers/leonardo/models.ts
8140
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
+ };
8141
8399
  var DEFAULT_LEONARDO_MODEL_ID = "7b592283-e8a7-4c5a-9ba6-d18c31f258b9";
8142
8400
  var LEONARDO_DEFAULTS = {
8143
8401
  modelId: process.env.LEONARDO_DEFAULT_MODEL ?? DEFAULT_LEONARDO_MODEL_ID,
@@ -8186,7 +8444,7 @@ function isGptImageModel(modelId) {
8186
8444
 
8187
8445
  // src/providers/leonardo/client.ts
8188
8446
  function requireLeonardoKey() {
8189
- const key = resolveKey("LEONARDO_API_KEY");
8447
+ const key = resolveRoutedKey(LEONARDO_ROUTING);
8190
8448
  if (!key) {
8191
8449
  throw new ConfigError(
8192
8450
  "LEONARDO_API_KEY is not set. Get one at https://app.leonardo.ai/settings/api-keys"
@@ -8195,7 +8453,7 @@ function requireLeonardoKey() {
8195
8453
  return key;
8196
8454
  }
8197
8455
  function leonardoBaseUrl() {
8198
- return resolveKey("LEONARDO_BASE_URL") ?? DEFAULT_LEONARDO_BASE_URL;
8456
+ return resolveProviderBaseUrl(LEONARDO_ROUTING);
8199
8457
  }
8200
8458
  var LeonardoClient = class {
8201
8459
  constructor(apiKey, baseUrl = leonardoBaseUrl()) {
@@ -9585,13 +9843,14 @@ async function postOpenAIForm(pathname, form, apiKey, timeoutMs = DEFAULT_TIMEOU
9585
9843
  const url = endpoint(pathname);
9586
9844
  let res;
9587
9845
  try {
9588
- res = await globalThis.fetch(url, {
9846
+ res = await fetchSameOrigin(url, {
9589
9847
  method: "POST",
9590
9848
  headers: authHeaders4(apiKey),
9591
9849
  body: form,
9592
9850
  signal: AbortSignal.timeout(timeoutMs)
9593
9851
  });
9594
9852
  } catch (cause) {
9853
+ if (cause instanceof HttpError) throw cause;
9595
9854
  throw new HttpError(0, cause instanceof Error ? cause.message : String(cause), url);
9596
9855
  }
9597
9856
  if (!res.ok) {
@@ -9604,13 +9863,14 @@ async function postOpenAIBinary(pathname, body, apiKey, timeoutMs = DEFAULT_TIME
9604
9863
  const url = endpoint(pathname);
9605
9864
  let res;
9606
9865
  try {
9607
- res = await globalThis.fetch(url, {
9866
+ res = await fetchSameOrigin(url, {
9608
9867
  method: "POST",
9609
9868
  headers: authHeaders4(apiKey, { "Content-Type": "application/json" }),
9610
9869
  body: JSON.stringify(body),
9611
9870
  signal: AbortSignal.timeout(timeoutMs)
9612
9871
  });
9613
9872
  } catch (cause) {
9873
+ if (cause instanceof HttpError) throw cause;
9614
9874
  throw new HttpError(0, cause instanceof Error ? cause.message : String(cause), url);
9615
9875
  }
9616
9876
  if (!res.ok) {
@@ -10450,7 +10710,9 @@ async function downloadOpenRouterVideo(opts) {
10450
10710
  const apiKey = requireOpenRouterKey();
10451
10711
  const url = videoContentUrl(jobId);
10452
10712
  const dest = path50.join(getOutputDir(), `openrouter_video_${jobId}.mp4`);
10453
- const r = await globalThis.fetch(url, { headers: { Authorization: `Bearer ${apiKey}` } });
10713
+ const { response: r } = await fetchWithValidatedRedirects(url, {
10714
+ headers: { Authorization: `Bearer ${apiKey}` }
10715
+ });
10454
10716
  if (!r.ok) {
10455
10717
  logger.error(`Download HTTP ${r.status}`);
10456
10718
  process.exit(1);
@@ -10461,8 +10723,8 @@ async function downloadOpenRouterVideo(opts) {
10461
10723
  }
10462
10724
  fs43.mkdirSync(path50.dirname(dest), { recursive: true });
10463
10725
  const ws = fs43.createWriteStream(dest);
10464
- const { pipeline: pipeline3 } = await import("stream/promises");
10465
- await pipeline3(r.body, ws);
10726
+ const { pipeline: pipeline2 } = await import("stream/promises");
10727
+ await pipeline2(r.body, ws);
10466
10728
  logger.success(`Saved ${dest}`);
10467
10729
  if (opts.output) {
10468
10730
  fs43.mkdirSync(path50.dirname(path50.resolve(opts.output)), { recursive: true });
@@ -10634,6 +10896,11 @@ function registerOpenRouterCommands(program2) {
10634
10896
  // src/providers/replicate/models.ts
10635
10897
  init_errors();
10636
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
+ };
10637
10904
  var DEFAULT_REPLICATE_IMAGE_MODEL = "black-forest-labs/flux-schnell";
10638
10905
  var DEFAULT_REPLICATE_VIDEO_MODEL = "prunaai/p-video";
10639
10906
  var TERMINAL_STATUSES = [
@@ -10924,13 +11191,18 @@ var ReplicateClient = class {
10924
11191
  const subject = `file upload ${opts.filename}`;
10925
11192
  let response;
10926
11193
  try {
10927
- response = await this.doFetch(url, {
10928
- method: "POST",
10929
- headers: this.headers(),
10930
- body: form,
10931
- signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS)
10932
- });
11194
+ response = await fetchSameOrigin(
11195
+ url,
11196
+ {
11197
+ method: "POST",
11198
+ headers: this.headers(),
11199
+ body: form,
11200
+ signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS)
11201
+ },
11202
+ this.doFetch
11203
+ );
10933
11204
  } catch (cause) {
11205
+ if (cause instanceof HttpError) throw mapReplicateError(cause, subject);
10934
11206
  const msg = cause instanceof Error ? cause.message : String(cause);
10935
11207
  throw mapReplicateError(new HttpError(0, msg, url), subject);
10936
11208
  }
@@ -10952,10 +11224,9 @@ var ReplicateClient = class {
10952
11224
  };
10953
11225
 
10954
11226
  // src/providers/replicate/node-client.ts
10955
- var REPLICATE_TOKEN_ENV = "REPLICATE_API_TOKEN";
10956
- var REPLICATE_TOKEN_FALLBACK_ENV = "REPLICATE_API_KEY";
11227
+ var [REPLICATE_TOKEN_ENV, REPLICATE_TOKEN_FALLBACK_ENV] = REPLICATE_ROUTING.keyEnvs;
10957
11228
  function resolveReplicateToken() {
10958
- const found = resolveFirstKey(REPLICATE_TOKEN_ENV, REPLICATE_TOKEN_FALLBACK_ENV);
11229
+ const found = resolveFirstKey(...REPLICATE_ROUTING.keyEnvs);
10959
11230
  return found ? { token: found.value, envUsed: found.envUsed } : void 0;
10960
11231
  }
10961
11232
  function requireReplicateToken() {
@@ -10968,7 +11239,7 @@ function requireReplicateToken() {
10968
11239
  return resolved.token;
10969
11240
  }
10970
11241
  function replicateBaseUrl() {
10971
- return resolveKey("REPLICATE_BASE_URL")?.trim() || DEFAULT_REPLICATE_BASE_URL;
11242
+ return resolveProviderBaseUrl(REPLICATE_ROUTING);
10972
11243
  }
10973
11244
  function replicateImageModel() {
10974
11245
  return resolveKey("REPLICATE_IMAGE_MODEL")?.trim() || DEFAULT_REPLICATE_IMAGE_MODEL;
@@ -11307,7 +11578,7 @@ async function saveOne(item, predictionId, index, count, opts) {
11307
11578
  fs44.writeFileSync(dest, bytes);
11308
11579
  return dest;
11309
11580
  }
11310
- const warned = opts.warnedHosts;
11581
+ const warned2 = opts.warnedHosts;
11311
11582
  try {
11312
11583
  const { dest } = await downloadToFile(item.url, {
11313
11584
  dest: (response) => {
@@ -11319,8 +11590,8 @@ async function saveOne(item, predictionId, index, count, opts) {
11319
11590
  fetch: opts.fetch,
11320
11591
  validateUrl: (url) => {
11321
11592
  const { host, trusted } = checkOutputUrl(url);
11322
- if (!trusted && !warned.has(host)) {
11323
- warned.add(host);
11593
+ if (!trusted && !warned2.has(host)) {
11594
+ warned2.add(host);
11324
11595
  opts.logger.warn(
11325
11596
  `Output ${item.path} is served from ${host}, not ${REPLICATE_DELIVERY_HOST}`
11326
11597
  );
@@ -11750,6 +12021,10 @@ var FFMPEG_HINT = "ffmpeg not found \u2014 media optimize/split/batch commands u
11750
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";
11751
12022
 
11752
12023
  // src/commands/check.ts
12024
+ function keyNames(routing) {
12025
+ const [envPrimary, envFallback] = routing.keyEnvs;
12026
+ return envFallback ? { envPrimary, envFallback } : { envPrimary };
12027
+ }
11753
12028
  var PROVIDERS = [
11754
12029
  {
11755
12030
  name: "Gemini",
@@ -11777,14 +12052,13 @@ var PROVIDERS = [
11777
12052
  },
11778
12053
  {
11779
12054
  name: "Leonardo.Ai",
11780
- envPrimary: "LEONARDO_API_KEY",
12055
+ ...keyNames(LEONARDO_ROUTING),
11781
12056
  optional: true,
11782
12057
  link: "https://app.leonardo.ai/settings/api-keys"
11783
12058
  },
11784
12059
  {
11785
12060
  name: "BytePlus",
11786
- envPrimary: "BYTEPLUS_API_KEY",
11787
- envFallback: "ARK_API_KEY",
12061
+ ...keyNames(BYTEPLUS_ROUTING),
11788
12062
  optional: true,
11789
12063
  link: "https://console.byteplus.com/auth/api-keys"
11790
12064
  },
@@ -11796,26 +12070,25 @@ var PROVIDERS = [
11796
12070
  },
11797
12071
  {
11798
12072
  name: "fal.ai",
11799
- envPrimary: "FAL_KEY",
12073
+ ...keyNames(FAL_ROUTING),
11800
12074
  optional: true,
11801
12075
  link: "https://fal.ai/dashboard/keys"
11802
12076
  },
11803
12077
  {
11804
12078
  name: "Atlas Cloud",
11805
- envPrimary: "ATLASCLOUD_API_KEY",
12079
+ ...keyNames(ATLAS_ROUTING),
11806
12080
  optional: true,
11807
12081
  link: "https://www.atlascloud.ai/console/api-keys"
11808
12082
  },
11809
12083
  {
11810
12084
  name: "Replicate",
11811
- envPrimary: "REPLICATE_API_TOKEN",
11812
- envFallback: "REPLICATE_API_KEY",
12085
+ ...keyNames(REPLICATE_ROUTING),
11813
12086
  optional: true,
11814
12087
  link: "https://replicate.com/account/api-tokens"
11815
12088
  },
11816
12089
  {
11817
12090
  name: "HeyGen",
11818
- envPrimary: "HEYGEN_API_KEY",
12091
+ ...keyNames(HEYGEN_ROUTING),
11819
12092
  optional: true,
11820
12093
  link: "https://app.heygen.com/developers/api"
11821
12094
  }
@@ -11830,14 +12103,64 @@ function resolveProviderKey(p) {
11830
12103
  return found ? { key: found.value, envUsed: found.envUsed } : {};
11831
12104
  }
11832
12105
  function cloudflareReadiness() {
11833
- const accountId = resolveKey("CLOUDFLARE_ACCOUNT_ID")?.trim();
11834
- const apiToken = resolveKey("CLOUDFLARE_API_TOKEN")?.trim();
11835
- const gatewayId = resolveKey("CLOUDFLARE_AI_GATEWAY_ID")?.trim();
11836
- const replicateToken = resolveKey("REPLICATE_API_TOKEN")?.trim();
11837
- return {
11838
- native: Boolean(accountId && apiToken),
11839
- 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);
11840
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;
11841
12164
  }
11842
12165
  async function checkCodexImageReadiness(hasAnyProviderKey, verbose = false, checker = checkCodexAuthenticated) {
11843
12166
  if (hasAnyProviderKey && !verbose) return false;
@@ -11901,18 +12224,31 @@ function registerCheckCommand(program2) {
11901
12224
  }
11902
12225
  }
11903
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
+ );
11904
12230
  if (cloudflare.native) {
11905
12231
  anyKey = true;
11906
12232
  logger.success("Cloudflare Workers AI credentials configured (image and speech)");
11907
12233
  if (cloudflare.video) {
11908
12234
  logger.success("Cloudflare AI Gateway video credentials configured");
12235
+ } else if (cloudflare.videoCheck.refused) {
12236
+ blocked("Cloudflare video");
11909
12237
  } else {
11910
- logger.info("Cloudflare video needs CLOUDFLARE_AI_GATEWAY_ID and REPLICATE_API_TOKEN");
12238
+ logger.info(`Cloudflare video unavailable: ${cloudflare.videoCheck.problem}`);
11911
12239
  }
12240
+ } else if (cloudflare.nativeCheck.refused) {
12241
+ blocked("Cloudflare image, speech and video");
11912
12242
  } else {
11913
- logger.info(
11914
- "Cloudflare media needs CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN (optional)"
11915
- );
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
+ }
11916
12252
  }
11917
12253
  const shouldCheckCodex = !anyKey || opts.verbose;
11918
12254
  let codexReady = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrgoonie/multix",
3
- "version": "0.15.2",
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.2 # 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