@mrgoonie/multix 0.7.3 → 0.9.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,20 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.9.0](https://github.com/mrgoonie/multix-cli/compare/v0.8.0...v0.9.0) (2026-10-11)
9
+
10
+
11
+ ### Added
12
+
13
+ * **core:** opt-in loading of legacy .claude env files ([#70](https://github.com/mrgoonie/multix-cli/issues/70)) ([48d9420](https://github.com/mrgoonie/multix-cli/commit/48d9420012a36ada0b5a35e2e51d90c1daa5042c))
14
+
15
+ ## [0.8.0](https://github.com/mrgoonie/multix-cli/compare/v0.7.3...v0.8.0) (2026-10-11)
16
+
17
+
18
+ ### Added
19
+
20
+ * **gemini:** rotate across GEMINI_API_KEY_N keys on quota errors ([#65](https://github.com/mrgoonie/multix-cli/issues/65)) ([d24efd3](https://github.com/mrgoonie/multix-cli/commit/d24efd36676d3823f9aac6e2aa0a8bd7a393b282))
21
+
8
22
  ## [0.7.3](https://github.com/mrgoonie/multix-cli/compare/v0.7.2...v0.7.3) (2026-10-11)
9
23
 
10
24
 
package/README.md CHANGED
@@ -56,6 +56,7 @@ Set at least one provider key. Add to `.env` in your project root or `~/.multix/
56
56
  | Variable | Required | Purpose |
57
57
  |---|---|---|
58
58
  | `GEMINI_API_KEY` | For Gemini | [AI Studio](https://aistudio.google.com/apikey) |
59
+ | `GEMINI_API_KEY_2`, `GEMINI_API_KEY_3`, … | No | Extra Gemini keys for quota rotation (see below) |
59
60
  | `OPENAI_API_KEY` | For OpenAI API | [OpenAI](https://platform.openai.com/api-keys) |
60
61
  | `OPENROUTER_API_KEY` | For OpenRouter | [OpenRouter](https://openrouter.ai/settings/keys) |
61
62
  | `MINIMAX_API_KEY` | For MiniMax | [MiniMax](https://platform.minimax.io/user-center/basic-information/interface-key) |
@@ -95,7 +96,11 @@ Set at least one provider key. Add to `.env` in your project root or `~/.multix/
95
96
  | `GEMINI_MODEL` / `MULTIMODAL_MODEL` | No | Override Gemini analyze/transcribe/extract model (doc convert uses `--model`; default `gemini-3.6-flash`) |
96
97
  | `GEMINI_TTS_MODEL` / `TTS_MODEL` | No | Override Gemini TTS model (default `gemini-3.8-flash-lite-tts`) |
97
98
 
98
- Priority: `process.env` > `cwd/.env` > `~/.multix/.env`.
99
+ ### Gemini key rotation
100
+
101
+ Set extra keys as `GEMINI_API_KEY_2`, `GEMINI_API_KEY_3`, … (any numeric suffix; numbered keys alone are enough). `multix` tries `GEMINI_API_KEY` first, then the numbered keys in ascending order, skipping duplicates. When a key returns HTTP 429 (or 400/403 with `RESOURCE_EXHAUSTED`), it cools down for the server's `Retry-After`/`retryDelay` (default 60s) and the same request is retried with the next ready key; other errors are not rotated. A Files API upload is repeated on the new key if the following generate call is rate-limited. Veo rotates on submit only, so a rate limit while polling or downloading never starts a second billed job. `doc convert` stops its own retries on a rate limit. Cooldowns last for the current process only, and logs name the env var, never the key. `multix check` lists the keys in rotation and pings each one. Quotas are per Google Cloud project: keys from the same project share one quota, so rotation only helps across projects.
102
+
103
+ 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.
99
104
 
100
105
  ## Commands
101
106
 
package/dist/cli.js CHANGED
@@ -280,22 +280,78 @@ init_redact();
280
280
  import fs3 from "fs";
281
281
  import os from "os";
282
282
  import path3 from "path";
283
- import { config as dotenvConfig } from "dotenv";
283
+ import { config as dotenvConfig, parse as dotenvParse } from "dotenv";
284
284
  var loaded = false;
285
+ var loadedFiles = [];
286
+ var LEGACY_SKILL_DIRS = ["ai-multimodal", "ak-ai-multimodal"];
287
+ function legacyFilesUnder(root) {
288
+ const claude = path3.join(root, ".claude");
289
+ return [
290
+ ...LEGACY_SKILL_DIRS.map((dir) => path3.join(claude, "skills", dir, ".env")),
291
+ path3.join(claude, "skills", ".env"),
292
+ path3.join(claude, ".env")
293
+ ];
294
+ }
295
+ function samePath(a, b) {
296
+ const norm = (p) => {
297
+ const r = path3.resolve(p);
298
+ return process.platform === "win32" ? r.toLowerCase() : r;
299
+ };
300
+ return norm(a) === norm(b);
301
+ }
302
+ function legacyClaudeEnvFiles(cwd, home) {
303
+ const files = legacyFilesUnder(cwd);
304
+ if (home && !samePath(home, cwd)) files.push(...legacyFilesUnder(home));
305
+ return files;
306
+ }
307
+ function legacyClaudeEnvEnabled(env = process.env) {
308
+ const value = env.MULTIX_LEGACY_CLAUDE_ENV;
309
+ return value !== void 0 && ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
310
+ }
311
+ var LEGACY_ALLOWED_NAME = /^[A-Z][A-Z0-9_]*_(API_KEY(_\d+)?|API_TOKEN|ACCOUNT_ID|GATEWAY_ID)$|^(FAL_KEY|ARK_API_KEY)$/;
312
+ function isLegacyAllowedName(name) {
313
+ return LEGACY_ALLOWED_NAME.test(name);
314
+ }
315
+ function isReadableFile(file) {
316
+ try {
317
+ return fs3.statSync(file).isFile();
318
+ } catch {
319
+ return false;
320
+ }
321
+ }
322
+ function loadFile(file) {
323
+ if (!isReadableFile(file)) return;
324
+ const result = dotenvConfig({ path: file, override: false });
325
+ if (!result.error) loadedFiles.push(file);
326
+ }
327
+ function loadLegacyFile(file) {
328
+ if (!isReadableFile(file)) return;
329
+ let parsed;
330
+ try {
331
+ parsed = dotenvParse(fs3.readFileSync(file));
332
+ } catch {
333
+ return;
334
+ }
335
+ for (const [name, value] of Object.entries(parsed)) {
336
+ if (isLegacyAllowedName(name) && process.env[name] === void 0) process.env[name] = value;
337
+ }
338
+ loadedFiles.push(file);
339
+ }
285
340
  function loadEnv() {
286
341
  if (loaded) return;
287
342
  loaded = true;
288
- const cwdEnv = path3.resolve(process.cwd(), ".env");
289
- if (fs3.existsSync(cwdEnv)) {
290
- dotenvConfig({ path: cwdEnv, override: false });
291
- }
292
- if (!process.env.MULTIX_DISABLE_HOME_ENV) {
293
- const homeEnv = path3.join(os.homedir(), ".multix", ".env");
294
- if (fs3.existsSync(homeEnv)) {
295
- dotenvConfig({ path: homeEnv, override: false });
296
- }
343
+ loadedFiles = [];
344
+ const cwd = process.cwd();
345
+ const home = process.env.MULTIX_DISABLE_HOME_ENV ? void 0 : os.homedir();
346
+ loadFile(path3.resolve(cwd, ".env"));
347
+ if (home) loadFile(path3.join(home, ".multix", ".env"));
348
+ if (legacyClaudeEnvEnabled()) {
349
+ for (const file of legacyClaudeEnvFiles(cwd, home)) loadLegacyFile(file);
297
350
  }
298
351
  }
352
+ function getLoadedEnvFiles() {
353
+ return [...loadedFiles];
354
+ }
299
355
  function resolveKey(name) {
300
356
  return process.env[name] || void 0;
301
357
  }
@@ -3090,9 +3146,72 @@ init_errors();
3090
3146
  import fs20 from "fs";
3091
3147
 
3092
3148
  // src/providers/gemini/client.ts
3149
+ init_errors();
3093
3150
  import fs18 from "fs";
3094
3151
  import path22 from "path";
3152
+
3153
+ // src/providers/gemini/key-pool.ts
3095
3154
  init_errors();
3155
+ var PRIMARY_KEY_ENV = "GEMINI_API_KEY";
3156
+ var DEFAULT_COOLDOWN_MS = 6e4;
3157
+ var cooldownUntil = /* @__PURE__ */ new Map();
3158
+ function listGeminiKeys(env = process.env) {
3159
+ const numbered = Object.keys(env).map((name) => ({ name, match: /^GEMINI_API_KEY_(\d+)$/.exec(name) })).filter((e) => e.match !== null).sort((a, b) => Number(a.match[1]) - Number(b.match[1])).map((e) => e.name);
3160
+ const seen = /* @__PURE__ */ new Set();
3161
+ const keys = [];
3162
+ for (const name of [PRIMARY_KEY_ENV, ...numbered]) {
3163
+ const value = env[name]?.trim();
3164
+ if (!value || seen.has(value)) continue;
3165
+ seen.add(value);
3166
+ keys.push({ name, value });
3167
+ }
3168
+ return keys;
3169
+ }
3170
+ function isGeminiRateLimitError(error) {
3171
+ if (!(error instanceof HttpError)) return false;
3172
+ if (error.status === 429) return true;
3173
+ return (error.status === 400 || error.status === 403) && error.snippet.includes("RESOURCE_EXHAUSTED");
3174
+ }
3175
+ function cooldownMs(error) {
3176
+ if (!(error instanceof HttpError)) return DEFAULT_COOLDOWN_MS;
3177
+ if (error.retryAfterMs !== void 0 && error.retryAfterMs > 0) return error.retryAfterMs;
3178
+ const delay = /"retryDelay"\s*:\s*"(\d+(?:\.\d+)?)s"/.exec(error.snippet);
3179
+ if (delay?.[1]) return Math.ceil(Number(delay[1]) * 1e3);
3180
+ return DEFAULT_COOLDOWN_MS;
3181
+ }
3182
+ function keysToTry(keys, now) {
3183
+ const until = (k) => cooldownUntil.get(k.value) ?? 0;
3184
+ const ready = keys.filter((k) => until(k) <= now);
3185
+ if (ready.length > 0) return ready;
3186
+ const soonest = [...keys].sort((a, b) => until(a) - until(b))[0];
3187
+ return soonest ? [soonest] : [];
3188
+ }
3189
+ async function withGeminiKey(fn, opts = {}) {
3190
+ const now = opts.now ?? Date.now;
3191
+ const keys = listGeminiKeys(opts.env);
3192
+ if (keys.length === 0) {
3193
+ throw new ConfigError(
3194
+ "GEMINI_API_KEY is not set. Get one at https://aistudio.google.com/apikey"
3195
+ );
3196
+ }
3197
+ const ordered = keysToTry(keys, now());
3198
+ let lastError;
3199
+ for (const [i, key] of ordered.entries()) {
3200
+ try {
3201
+ return await fn(key.value, key.name);
3202
+ } catch (error) {
3203
+ if (!isGeminiRateLimitError(error)) throw error;
3204
+ lastError = error;
3205
+ cooldownUntil.set(key.value, now() + cooldownMs(error));
3206
+ const next = ordered[i + 1];
3207
+ if (next) opts.logger?.warn(`${key.name} is rate-limited; retrying with ${next.name}`);
3208
+ }
3209
+ }
3210
+ if (keys.length > 1) {
3211
+ opts.logger?.warn(`All ${keys.length} Gemini keys are rate-limited; try again later`);
3212
+ }
3213
+ throw lastError;
3214
+ }
3096
3215
 
3097
3216
  // src/providers/gemini/task-resolver.ts
3098
3217
  import path21 from "path";
@@ -3147,19 +3266,16 @@ function requiresProcessingWait(mimeType) {
3147
3266
  var BASE = "https://generativelanguage.googleapis.com";
3148
3267
  var FILES_BASE = `${BASE}/upload/v1beta/files`;
3149
3268
  var GENERATE_BASE = `${BASE}/v1beta/models`;
3150
- function requireApiKey() {
3151
- const key = resolveKey("GEMINI_API_KEY");
3152
- if (!key)
3153
- throw new ConfigError(
3154
- "GEMINI_API_KEY is not set. Get one at https://aistudio.google.com/apikey"
3155
- );
3156
- return key;
3269
+ function withKey(opts, fn) {
3270
+ return opts.apiKey ? fn(opts.apiKey) : withGeminiKey(fn, { logger: opts.logger });
3157
3271
  }
3158
3272
  function authHeaders2(apiKey) {
3159
3273
  return { "x-goog-api-key": apiKey };
3160
3274
  }
3161
3275
  async function uploadFile(filePath, opts = {}) {
3162
- const apiKey = requireApiKey();
3276
+ return withKey(opts, (apiKey) => uploadFileWithKey(filePath, apiKey, opts));
3277
+ }
3278
+ async function uploadFileWithKey(filePath, apiKey, opts) {
3163
3279
  const { timeoutMs = 3e5, logger } = opts;
3164
3280
  const mimeType = getMimeType(filePath);
3165
3281
  const fileBytes = fs18.readFileSync(filePath);
@@ -3195,10 +3311,7 @@ Content-Type: ${mimeType}\r
3195
3311
  });
3196
3312
  if (!resp.ok) {
3197
3313
  const txt = await resp.text();
3198
- throw new ProviderError(
3199
- `File upload failed (HTTP ${resp.status}): ${txt.slice(0, 500)}`,
3200
- "gemini"
3201
- );
3314
+ throw new HttpError(resp.status, `File upload failed: ${txt.slice(0, 500)}`, FILES_BASE);
3202
3315
  }
3203
3316
  const data = await resp.json();
3204
3317
  let fileRef = data.file;
@@ -3220,26 +3333,30 @@ Content-Type: ${mimeType}\r
3220
3333
  logger?.debug(`Uploaded: ${fileRef.name} (${fileRef.uri})`);
3221
3334
  return fileRef;
3222
3335
  }
3223
- async function generateContent(req) {
3224
- const apiKey = requireApiKey();
3225
- return httpJson({
3226
- url: `${GENERATE_BASE}/${req.model}:generateContent`,
3227
- method: "POST",
3228
- headers: authHeaders2(apiKey),
3229
- body: {
3230
- contents: req.contents,
3231
- ...req.generationConfig ? { generationConfig: req.generationConfig } : {}
3232
- }
3233
- });
3336
+ async function generateContent(req, opts = {}) {
3337
+ return withKey(
3338
+ opts,
3339
+ (apiKey) => httpJson({
3340
+ url: `${GENERATE_BASE}/${req.model}:generateContent`,
3341
+ method: "POST",
3342
+ headers: authHeaders2(apiKey),
3343
+ body: {
3344
+ contents: req.contents,
3345
+ ...req.generationConfig ? { generationConfig: req.generationConfig } : {}
3346
+ }
3347
+ })
3348
+ );
3234
3349
  }
3235
- async function createInteraction(body) {
3236
- const apiKey = requireApiKey();
3237
- return httpJson({
3238
- url: `${BASE}/v1beta/interactions`,
3239
- method: "POST",
3240
- headers: authHeaders2(apiKey),
3241
- body
3242
- });
3350
+ async function createInteraction(body, opts = {}) {
3351
+ return withKey(
3352
+ opts,
3353
+ (apiKey) => httpJson({
3354
+ url: `${BASE}/v1beta/interactions`,
3355
+ method: "POST",
3356
+ headers: authHeaders2(apiKey),
3357
+ body
3358
+ })
3359
+ );
3243
3360
  }
3244
3361
  function extractInteractionAudio(resp) {
3245
3362
  let found = null;
@@ -3418,22 +3535,30 @@ function registerAnalyzeCommand(parent) {
3418
3535
  const filePath = opts.files[i];
3419
3536
  logger.debug(`[${i + 1}/${opts.files.length}] Processing: ${filePath}`);
3420
3537
  try {
3421
- const useApi = shouldUseFileApi(filePath);
3422
- let contentParts;
3423
- if (useApi) {
3424
- logger.debug("File >20MB \u2014 using Files API");
3425
- const ref = await uploadFile(filePath, { logger });
3426
- contentParts = [
3427
- { text: opts.prompt },
3428
- { fileData: { mimeType: ref.mimeType, fileUri: ref.uri } }
3429
- ];
3430
- } else {
3431
- contentParts = [{ text: opts.prompt }, inlinePart(filePath)];
3432
- }
3433
- const resp = await generateContent({
3434
- model,
3435
- contents: [{ parts: contentParts }]
3436
- });
3538
+ const resp = await withGeminiKey(
3539
+ async (apiKey) => {
3540
+ const useApi = shouldUseFileApi(filePath);
3541
+ let contentParts;
3542
+ if (useApi) {
3543
+ logger.debug("File >20MB \u2014 using Files API");
3544
+ const ref = await uploadFile(filePath, { logger, apiKey });
3545
+ contentParts = [
3546
+ { text: opts.prompt },
3547
+ { fileData: { mimeType: ref.mimeType, fileUri: ref.uri } }
3548
+ ];
3549
+ } else {
3550
+ contentParts = [{ text: opts.prompt }, inlinePart(filePath)];
3551
+ }
3552
+ return generateContent(
3553
+ {
3554
+ model,
3555
+ contents: [{ parts: contentParts }]
3556
+ },
3557
+ { apiKey }
3558
+ );
3559
+ },
3560
+ { logger }
3561
+ );
3437
3562
  results.push({
3438
3563
  file: filePath,
3439
3564
  status: "success",
@@ -3472,22 +3597,30 @@ function registerExtractCommand(parent) {
3472
3597
  const filePath = opts.files[i];
3473
3598
  logger.debug(`[${i + 1}/${opts.files.length}] Extracting: ${filePath}`);
3474
3599
  try {
3475
- const useApi = shouldUseFileApi(filePath);
3476
- let contentParts;
3477
- if (useApi) {
3478
- const ref = await uploadFile(filePath, { logger });
3479
- contentParts = [
3480
- { text: opts.prompt },
3481
- { fileData: { mimeType: ref.mimeType, fileUri: ref.uri } }
3482
- ];
3483
- } else {
3484
- contentParts = [{ text: opts.prompt }, inlinePart(filePath)];
3485
- }
3486
- const resp = await generateContent({
3487
- model,
3488
- contents: [{ parts: contentParts }],
3489
- generationConfig
3490
- });
3600
+ const resp = await withGeminiKey(
3601
+ async (apiKey) => {
3602
+ const useApi = shouldUseFileApi(filePath);
3603
+ let contentParts;
3604
+ if (useApi) {
3605
+ const ref = await uploadFile(filePath, { logger, apiKey });
3606
+ contentParts = [
3607
+ { text: opts.prompt },
3608
+ { fileData: { mimeType: ref.mimeType, fileUri: ref.uri } }
3609
+ ];
3610
+ } else {
3611
+ contentParts = [{ text: opts.prompt }, inlinePart(filePath)];
3612
+ }
3613
+ return generateContent(
3614
+ {
3615
+ model,
3616
+ contents: [{ parts: contentParts }],
3617
+ generationConfig
3618
+ },
3619
+ { apiKey }
3620
+ );
3621
+ },
3622
+ { logger }
3623
+ );
3491
3624
  results.push({ file: filePath, status: "success", response: extractText(resp) });
3492
3625
  } catch (e) {
3493
3626
  const msg = e instanceof Error ? e.message : String(e);
@@ -3591,11 +3724,14 @@ async function generateGeminiSpeech(opts) {
3591
3724
  logger?.debug(`Gemini TTS model=${model} mode=${speakers?.length ? "multi" : "single"}`);
3592
3725
  let resp;
3593
3726
  try {
3594
- resp = await generateContent({
3595
- model,
3596
- contents: [{ parts: [{ text }] }],
3597
- generationConfig
3598
- });
3727
+ resp = await generateContent(
3728
+ {
3729
+ model,
3730
+ contents: [{ parts: [{ text }] }],
3731
+ generationConfig
3732
+ },
3733
+ { logger }
3734
+ );
3599
3735
  } catch (e) {
3600
3736
  return { status: "error", error: e instanceof Error ? e.message : String(e) };
3601
3737
  }
@@ -3631,7 +3767,7 @@ async function generateInteractionsSpeech(opts) {
3631
3767
  }
3632
3768
  let resp;
3633
3769
  try {
3634
- resp = await createInteraction(buildInteractionsSpeechBody(opts));
3770
+ resp = await createInteraction(buildInteractionsSpeechBody(opts), { logger });
3635
3771
  } catch (e) {
3636
3772
  return { status: "error", error: e instanceof Error ? e.message : String(e) };
3637
3773
  }
@@ -3787,9 +3923,9 @@ function collectSpeakers(value, prev) {
3787
3923
  }
3788
3924
 
3789
3925
  // src/providers/gemini/commands/generate-video.ts
3926
+ init_errors();
3790
3927
  import fs24 from "fs";
3791
3928
  import path25 from "path";
3792
- init_errors();
3793
3929
 
3794
3930
  // src/providers/gemini/veo.ts
3795
3931
  import fs23 from "fs";
@@ -3907,6 +4043,7 @@ async function submitVeo(apiKey, model, body) {
3907
4043
  body
3908
4044
  });
3909
4045
  } catch (e) {
4046
+ if (e instanceof HttpError) throw e;
3910
4047
  const msg = e instanceof Error ? e.message : String(e);
3911
4048
  throw new ProviderError(`Veo submit failed: ${msg}`, "gemini");
3912
4049
  }
@@ -3944,8 +4081,12 @@ async function downloadVeoVideo(apiKey, uri, dest) {
3944
4081
  }
3945
4082
  await downloadFile(uri, dest, 3e5, host === API_HOST ? headers(apiKey) : void 0);
3946
4083
  }
3947
- async function runVeo(apiKey, model, request, dest, logger) {
3948
- const operation = await submitVeo(apiKey, model, buildVeoRequest(request));
4084
+ async function runVeo(model, request, dest, logger) {
4085
+ const body = buildVeoRequest(request);
4086
+ const { apiKey, operation } = await withGeminiKey(
4087
+ async (key) => ({ apiKey: key, operation: await submitVeo(key, model, body) }),
4088
+ { logger }
4089
+ );
3949
4090
  logger.debug(`Operation: ${operation.name}`);
3950
4091
  const finalOp = await waitForVeo(apiKey, operation, logger);
3951
4092
  const uri = extractVeoVideoUri(finalOp);
@@ -3964,11 +4105,12 @@ function registerGenerateVideoCommand(parent) {
3964
4105
  parent.command("generate-video").description("Generate video with Veo via Gemini API [EXPERIMENTAL]").requiredOption("--prompt <text>", "Video generation prompt").option("--model <id>", "Veo model to use").option("--resolution <res>", "Video resolution: 720p|1080p", "1080p").option("--aspect-ratio <ratio>", "Aspect ratio", "16:9").option(
3965
4106
  "--reference-images <paths...>",
3966
4107
  "Reference images (first=opening frame, second=closing)"
3967
- ).option("--upload-timeout <ms>", "Upload timeout in ms", "120000").option("--output <path>", "Copy generated video to this path").option("--no-thumb", "Skip downloading the thumbnail if the API returns one").option("-v, --verbose", "Verbose logging").action(
4108
+ ).option("--upload-timeout <ms>", "Deprecated and ignored (images are sent inline)").option("--output <path>", "Copy generated video to this path").option("--no-thumb", "Skip downloading the thumbnail if the API returns one").option("-v, --verbose", "Verbose logging").action(
3968
4109
  async (opts) => {
3969
4110
  const logger = createLogger({ verbose: opts.verbose ?? false });
3970
- const apiKey = resolveKey("GEMINI_API_KEY");
3971
- if (!apiKey) throw new ValidationError("GEMINI_API_KEY is not set");
4111
+ if (opts.uploadTimeout !== void 0) {
4112
+ logger.warn("--upload-timeout is deprecated and ignored (images are sent inline)");
4113
+ }
3972
4114
  const model = opts.model ?? getDefaultModel("generate-video");
3973
4115
  if (!model.startsWith("veo-")) {
3974
4116
  throw new ValidationError(
@@ -3986,7 +4128,6 @@ function registerGenerateVideoCommand(parent) {
3986
4128
  const frames = veoFramesFromPaths(opts.referenceImages);
3987
4129
  const dest = path25.join(getOutputDir(), `veo_generated_${Date.now()}.mp4`);
3988
4130
  const finalOp = await runVeo(
3989
- apiKey,
3990
4131
  model,
3991
4132
  {
3992
4133
  prompt: opts.prompt,
@@ -4043,11 +4184,14 @@ function registerGenerateCommand(parent) {
4043
4184
  const savedFiles = [];
4044
4185
  for (let i = 0; i < numImages; i++) {
4045
4186
  try {
4046
- const resp = await generateContent({
4047
- model,
4048
- contents: [{ parts: [{ text: opts.prompt }] }],
4049
- generationConfig
4050
- });
4187
+ const resp = await generateContent(
4188
+ {
4189
+ model,
4190
+ contents: [{ parts: [{ text: opts.prompt }] }],
4191
+ generationConfig
4192
+ },
4193
+ { logger }
4194
+ );
4051
4195
  const images = extractImages(resp);
4052
4196
  if (images.length === 0) {
4053
4197
  logger.warn(
@@ -4135,7 +4279,7 @@ function registerGeminiImageToImageCommand(parent) {
4135
4279
  contents: [{ parts }],
4136
4280
  generationConfig: { responseModalities: ["IMAGE"] }
4137
4281
  };
4138
- const resp = await generateContent(req);
4282
+ const resp = await generateContent(req, { logger });
4139
4283
  const images = extractImages(resp);
4140
4284
  if (images.length === 0) {
4141
4285
  logger.error(
@@ -4193,15 +4337,13 @@ function sniffImageMime(buf, url) {
4193
4337
  }
4194
4338
 
4195
4339
  // src/providers/gemini/commands/image-to-video.ts
4340
+ init_errors();
4196
4341
  import fs27 from "fs";
4197
4342
  import path28 from "path";
4198
- init_errors();
4199
4343
  function registerGeminiImageToVideoCommand(parent) {
4200
4344
  parent.command("image-to-video <imagePath>").alias("i2v").description("Generate video from a starting image with Veo (image-to-video) [EXPERIMENTAL]").requiredOption("--prompt <text>", "Motion/scene prompt").option("--last-frame <path>", "Optional closing frame image (Veo 3.1)").option("--model <id>", "Veo model id").option("--resolution <res>", "720p|1080p", "1080p").option("--aspect-ratio <ratio>", "Aspect ratio", "16:9").option("--output <path>", "Copy generated video to this path").option("--no-thumb", "Skip downloading the thumbnail if the API returns one").option("-v, --verbose", "Verbose logging").action(
4201
4345
  async (imagePath, opts) => {
4202
4346
  const logger = createLogger({ verbose: opts.verbose ?? false });
4203
- const apiKey = resolveKey("GEMINI_API_KEY");
4204
- if (!apiKey) throw new ValidationError("GEMINI_API_KEY is not set");
4205
4347
  const model = opts.model ?? getDefaultModel("generate-video");
4206
4348
  if (!model.startsWith("veo-")) {
4207
4349
  throw new ValidationError(
@@ -4219,7 +4361,6 @@ function registerGeminiImageToVideoCommand(parent) {
4219
4361
  );
4220
4362
  const dest = path28.join(getOutputDir(), `veo_i2v_${Date.now()}.mp4`);
4221
4363
  const finalOp = await runVeo(
4222
- apiKey,
4223
4364
  model,
4224
4365
  {
4225
4366
  prompt: opts.prompt,
@@ -4264,22 +4405,30 @@ function registerTranscribeCommand2(parent) {
4264
4405
  const filePath = opts.files[i];
4265
4406
  logger.debug(`[${i + 1}/${opts.files.length}] Transcribing: ${filePath}`);
4266
4407
  try {
4267
- const useApi = shouldUseFileApi(filePath);
4268
- let contentParts;
4269
- if (useApi) {
4270
- logger.debug("File >20MB \u2014 using Files API");
4271
- const ref = await uploadFile(filePath, { logger });
4272
- contentParts = [
4273
- { text: opts.prompt },
4274
- { fileData: { mimeType: ref.mimeType, fileUri: ref.uri } }
4275
- ];
4276
- } else {
4277
- contentParts = [{ text: opts.prompt }, inlinePart(filePath)];
4278
- }
4279
- const resp = await generateContent({
4280
- model,
4281
- contents: [{ parts: contentParts }]
4282
- });
4408
+ const resp = await withGeminiKey(
4409
+ async (apiKey) => {
4410
+ const useApi = shouldUseFileApi(filePath);
4411
+ let contentParts;
4412
+ if (useApi) {
4413
+ logger.debug("File >20MB \u2014 using Files API");
4414
+ const ref = await uploadFile(filePath, { logger, apiKey });
4415
+ contentParts = [
4416
+ { text: opts.prompt },
4417
+ { fileData: { mimeType: ref.mimeType, fileUri: ref.uri } }
4418
+ ];
4419
+ } else {
4420
+ contentParts = [{ text: opts.prompt }, inlinePart(filePath)];
4421
+ }
4422
+ return generateContent(
4423
+ {
4424
+ model,
4425
+ contents: [{ parts: contentParts }]
4426
+ },
4427
+ { apiKey }
4428
+ );
4429
+ },
4430
+ { logger }
4431
+ );
4283
4432
  results.push({
4284
4433
  file: filePath,
4285
4434
  status: "success",
@@ -6815,6 +6964,10 @@ function registerOpenRouterCommands(program2) {
6815
6964
  registerOpenRouterVideoModelsCommand(openrouter);
6816
6965
  }
6817
6966
 
6967
+ // src/commands/check.ts
6968
+ import fs43 from "fs";
6969
+ import os3 from "os";
6970
+
6818
6971
  // src/commands/check-helpers/binary-check.ts
6819
6972
  import { execa as execa2 } from "execa";
6820
6973
  async function checkBinary(binary, versionFlag = "--version") {
@@ -6940,6 +7093,10 @@ var PROVIDERS = [
6940
7093
  }
6941
7094
  ];
6942
7095
  function resolveProviderKey(p) {
7096
+ if (p.envPrimary === "GEMINI_API_KEY") {
7097
+ const first = listGeminiKeys()[0];
7098
+ return first ? { key: first.value, envUsed: first.name } : {};
7099
+ }
6943
7100
  const primary = resolveKey(p.envPrimary);
6944
7101
  if (primary) return { key: primary, envUsed: p.envPrimary };
6945
7102
  if (p.envFallback) {
@@ -6982,6 +7139,19 @@ function registerCheckCommand(program2) {
6982
7139
  if (opts.verbose) console.log(` ${MAGICK_HINT}`);
6983
7140
  }
6984
7141
  logger.header("API Keys");
7142
+ if (opts.verbose) {
7143
+ const files = getLoadedEnvFiles();
7144
+ logger.info(`Env files loaded: ${files.length > 0 ? files.join(", ") : "none"}`);
7145
+ }
7146
+ if (!legacyClaudeEnvEnabled()) {
7147
+ const home = process.env.MULTIX_DISABLE_HOME_ENV ? void 0 : os3.homedir();
7148
+ const legacy = legacyClaudeEnvFiles(process.cwd(), home).filter((f) => fs43.existsSync(f));
7149
+ if (legacy.length > 0) {
7150
+ logger.info(
7151
+ `Found ${legacy.length} legacy .claude env file(s); set MULTIX_LEGACY_CLAUDE_ENV=1 to load them`
7152
+ );
7153
+ }
7154
+ }
6985
7155
  let anyKey = false;
6986
7156
  const resolved = /* @__PURE__ */ new Map();
6987
7157
  for (const p of PROVIDERS) {
@@ -6991,6 +7161,14 @@ function registerCheckCommand(program2) {
6991
7161
  const envLabel = r.envUsed ?? p.envPrimary;
6992
7162
  if (r.key) {
6993
7163
  logger.success(`${envLabel} found \u2014 ${redact(r.key)}`);
7164
+ if (p.name === "Gemini") {
7165
+ const pool = listGeminiKeys();
7166
+ if (pool.length > 1) {
7167
+ logger.info(
7168
+ `Gemini key rotation: ${pool.length} keys (${pool.map((k) => k.name).join(", ")})`
7169
+ );
7170
+ }
7171
+ }
6994
7172
  } else if (p.optional) {
6995
7173
  const fbNote = p.envFallback ? ` (or ${p.envFallback})` : "";
6996
7174
  logger.info(`${p.envPrimary}${fbNote} not set (optional)`);
@@ -7036,14 +7214,18 @@ function registerCheckCommand(program2) {
7036
7214
  if (geminiKey) {
7037
7215
  logger.header("Gemini Connectivity");
7038
7216
  logger.info("Pinging Gemini models endpoint...");
7039
- const ping = await pingGemini(geminiKey);
7040
- if (ping.status === "success") {
7041
- logger.success(`Gemini API reachable \u2014 ${ping.modelCount ?? 0} models available`);
7042
- } else if (ping.status === "auth_error") {
7043
- logger.error(`Gemini auth failed \u2014 check GEMINI_API_KEY: ${ping.error ?? ""}`);
7044
- exitCode = 1;
7045
- } else {
7046
- logger.warn(`Gemini network error (API may be down): ${ping.error ?? ""}`);
7217
+ for (const key of listGeminiKeys()) {
7218
+ const ping = await pingGemini(key.value);
7219
+ if (ping.status === "success") {
7220
+ logger.success(
7221
+ `${key.name}: Gemini API reachable \u2014 ${ping.modelCount ?? 0} models available`
7222
+ );
7223
+ } else if (ping.status === "auth_error") {
7224
+ logger.error(`Gemini auth failed \u2014 check ${key.name}: ${ping.error ?? ""}`);
7225
+ exitCode = 1;
7226
+ } else {
7227
+ logger.warn(`${key.name}: Gemini network error (API may be down): ${ping.error ?? ""}`);
7228
+ }
7047
7229
  }
7048
7230
  }
7049
7231
  logger.header("Summary");
@@ -7068,7 +7250,7 @@ function registerCheckCommand(program2) {
7068
7250
 
7069
7251
  // src/commands/doc/convert.ts
7070
7252
  init_errors();
7071
- import fs43 from "fs";
7253
+ import fs44 from "fs";
7072
7254
  import path45 from "path";
7073
7255
 
7074
7256
  // src/commands/doc/default-prompt.ts
@@ -7090,7 +7272,7 @@ async function convertDocuments(opts) {
7090
7272
  const model = opts.model ?? DOC_MODEL_DEFAULT;
7091
7273
  const prompt = opts.prompt ?? DEFAULT_CONVERT_PROMPT;
7092
7274
  for (const f of inputs) {
7093
- if (!fs43.existsSync(f)) throw new ValidationError(`Input file not found: ${f}`);
7275
+ if (!fs44.existsSync(f)) throw new ValidationError(`Input file not found: ${f}`);
7094
7276
  }
7095
7277
  let outputPath;
7096
7278
  if (output) {
@@ -7101,7 +7283,7 @@ async function convertDocuments(opts) {
7101
7283
  } else {
7102
7284
  outputPath = path45.join(getOutputDir(), "document-extraction.md");
7103
7285
  }
7104
- fs43.mkdirSync(path45.dirname(outputPath), { recursive: true });
7286
+ fs44.mkdirSync(path45.dirname(outputPath), { recursive: true });
7105
7287
  const results = [];
7106
7288
  for (let i = 0; i < inputs.length; i++) {
7107
7289
  const filePath = inputs[i];
@@ -7110,26 +7292,35 @@ async function convertDocuments(opts) {
7110
7292
  let errorMsg;
7111
7293
  for (let attempt = 0; attempt < 3; attempt++) {
7112
7294
  try {
7113
- const useApi = shouldUseFileApi(filePath);
7114
- let contentParts;
7115
- if (useApi) {
7116
- logger?.debug("File >20MB \u2014 using Files API");
7117
- const ref = await uploadFile(filePath, { logger });
7118
- contentParts = [
7119
- { text: prompt },
7120
- { fileData: { mimeType: ref.mimeType, fileUri: ref.uri } }
7121
- ];
7122
- } else {
7123
- contentParts = [{ text: prompt }, inlinePart(filePath)];
7124
- }
7125
- const resp = await generateContent({
7126
- model,
7127
- contents: [{ parts: contentParts }]
7128
- });
7295
+ const resp = await withGeminiKey(
7296
+ async (apiKey) => {
7297
+ const useApi = shouldUseFileApi(filePath);
7298
+ let contentParts;
7299
+ if (useApi) {
7300
+ logger?.debug("File >20MB \u2014 using Files API");
7301
+ const ref = await uploadFile(filePath, { logger, apiKey });
7302
+ contentParts = [
7303
+ { text: prompt },
7304
+ { fileData: { mimeType: ref.mimeType, fileUri: ref.uri } }
7305
+ ];
7306
+ } else {
7307
+ contentParts = [{ text: prompt }, inlinePart(filePath)];
7308
+ }
7309
+ return generateContent(
7310
+ {
7311
+ model,
7312
+ contents: [{ parts: contentParts }]
7313
+ },
7314
+ { apiKey }
7315
+ );
7316
+ },
7317
+ { logger }
7318
+ );
7129
7319
  markdown = extractText(resp);
7130
7320
  break;
7131
7321
  } catch (e) {
7132
7322
  errorMsg = e instanceof Error ? e.message : String(e);
7323
+ if (isGeminiRateLimitError(e)) break;
7133
7324
  if (attempt < 2) {
7134
7325
  const wait = 2 ** attempt * 1e3;
7135
7326
  logger?.debug(`Retry ${attempt + 1} after ${wait / 1e3}s: ${errorMsg}`);
@@ -7165,7 +7356,7 @@ async function convertDocuments(opts) {
7165
7356
  }
7166
7357
  lines.push("\n---\n");
7167
7358
  }
7168
- fs43.writeFileSync(outputPath, lines.join("\n"), "utf8");
7359
+ fs44.writeFileSync(outputPath, lines.join("\n"), "utf8");
7169
7360
  const succeeded = results.filter((r) => r.status === "success").length;
7170
7361
  console.log(`
7171
7362
  ${"=".repeat(50)}`);
@@ -7213,11 +7404,11 @@ function registerDocCommands(program2) {
7213
7404
 
7214
7405
  // src/commands/media/index.ts
7215
7406
  init_errors();
7216
- import fs46 from "fs";
7407
+ import fs47 from "fs";
7217
7408
  import path48 from "path";
7218
7409
 
7219
7410
  // src/commands/media/batch.ts
7220
- import fs44 from "fs";
7411
+ import fs45 from "fs";
7221
7412
  import path46 from "path";
7222
7413
 
7223
7414
  // src/commands/media/detect-type.ts
@@ -7414,8 +7605,8 @@ async function batchOptimize(opts) {
7414
7605
  verbose,
7415
7606
  logger
7416
7607
  } = opts;
7417
- fs44.mkdirSync(outputDir, { recursive: true });
7418
- const entries = fs44.readdirSync(inputDir);
7608
+ fs45.mkdirSync(outputDir, { recursive: true });
7609
+ const entries = fs45.readdirSync(inputDir);
7419
7610
  const mediaFiles = entries.filter((f) => {
7420
7611
  const ext = path46.extname(f).toLowerCase();
7421
7612
  return ALL_EXTS.has(ext);
@@ -7467,7 +7658,7 @@ async function batchOptimize(opts) {
7467
7658
 
7468
7659
  // src/commands/media/split-video.ts
7469
7660
  init_errors();
7470
- import fs45 from "fs";
7661
+ import fs46 from "fs";
7471
7662
  import path47 from "path";
7472
7663
  async function splitVideo(opts) {
7473
7664
  await assertFfmpeg();
@@ -7479,7 +7670,7 @@ async function splitVideo(opts) {
7479
7670
  logger?.info("Video is short enough \u2014 no splitting needed");
7480
7671
  return [input];
7481
7672
  }
7482
- fs45.mkdirSync(outputDir, { recursive: true });
7673
+ fs46.mkdirSync(outputDir, { recursive: true });
7483
7674
  const ext = path47.extname(input) || ".mp4";
7484
7675
  const baseName = path47.basename(input, ext);
7485
7676
  const outputPattern = path47.join(outputDir, `${baseName}_chunk_%03d${ext}`);
@@ -7502,7 +7693,7 @@ async function splitVideo(opts) {
7502
7693
  const files = [];
7503
7694
  for (let i = 0; i < numChunks + 1; i++) {
7504
7695
  const fname = path47.join(outputDir, `${baseName}_chunk_${String(i).padStart(3, "0")}${ext}`);
7505
- if (fs45.existsSync(fname)) files.push(fname);
7696
+ if (fs46.existsSync(fname)) files.push(fname);
7506
7697
  }
7507
7698
  logger?.success(`Created ${files.length} chunks in ${outputDir}`);
7508
7699
  return files;
@@ -7516,8 +7707,8 @@ function registerMediaCommands(program2) {
7516
7707
  media.command("optimize").description("Optimize a single media file (video, audio, or image)").requiredOption("--input <file>", "Input media file").requiredOption("--output <file>", "Output file path").option("--target-size <MB>", "Target file size in MB").option("--quality <n>", "Quality: video CRF (0-51) or image (1-100)", "85").option("--max-width <px>", "Max image/video width in pixels", "1920").option("--bitrate <rate>", "Audio bitrate (e.g. 64k)", "64k").option("--resolution <WxH>", "Force video resolution (e.g. 1920x1080)").option("-v, --verbose", "Verbose logging").action(
7517
7708
  async (opts) => {
7518
7709
  const logger = createLogger({ verbose: opts.verbose ?? false });
7519
- if (!fs46.existsSync(opts.input)) throw new ValidationError(`Input not found: ${opts.input}`);
7520
- fs46.mkdirSync(path48.dirname(path48.resolve(opts.output)), { recursive: true });
7710
+ if (!fs47.existsSync(opts.input)) throw new ValidationError(`Input not found: ${opts.input}`);
7711
+ fs47.mkdirSync(path48.dirname(path48.resolve(opts.output)), { recursive: true });
7521
7712
  const ext = path48.extname(opts.input).toLowerCase();
7522
7713
  const kind = extToKind(ext);
7523
7714
  if (!kind) throw new ValidationError(`Unsupported file type: ${ext}`);
@@ -7558,7 +7749,7 @@ function registerMediaCommands(program2) {
7558
7749
  media.command("split").description("Split a long video into fixed-duration chunks").requiredOption("--input <file>", "Input video file").option("--output-dir <dir>", "Output directory for chunks", "./chunks").option("--chunk-duration <sec>", "Chunk duration in seconds", "3600").option("-v, --verbose", "Verbose logging").action(
7559
7750
  async (opts) => {
7560
7751
  const logger = createLogger({ verbose: opts.verbose ?? false });
7561
- if (!fs46.existsSync(opts.input)) throw new ValidationError(`Input not found: ${opts.input}`);
7752
+ if (!fs47.existsSync(opts.input)) throw new ValidationError(`Input not found: ${opts.input}`);
7562
7753
  const chunks = await splitVideo({
7563
7754
  input: opts.input,
7564
7755
  outputDir: opts.outputDir,
@@ -7574,7 +7765,7 @@ Created ${chunks.length} chunk(s) in ${opts.outputDir}`);
7574
7765
  media.command("batch").description("Batch optimize all media files in a directory").requiredOption("--input-dir <dir>", "Input directory").requiredOption("--output-dir <dir>", "Output directory").option("--quality <n>", "Quality setting", "85").option("--max-width <px>", "Max image/video width", "1920").option("--bitrate <rate>", "Audio bitrate", "64k").option("-v, --verbose", "Verbose logging").action(
7575
7766
  async (opts) => {
7576
7767
  const logger = createLogger({ verbose: opts.verbose ?? false });
7577
- if (!fs46.existsSync(opts.inputDir))
7768
+ if (!fs47.existsSync(opts.inputDir))
7578
7769
  throw new ValidationError(`Input dir not found: ${opts.inputDir}`);
7579
7770
  const result = await batchOptimize({
7580
7771
  inputDir: opts.inputDir,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrgoonie/multix",
3
- "version": "0.7.3",
3
+ "version": "0.9.0",
4
4
  "description": "AI multimodal CLI — OpenAI, Gemini, MiniMax, OpenRouter, Leonardo, BytePlus, ElevenLabs, fal.ai, 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), 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), 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, video generation, text-to-video, image-to-video, i2v, ElevenLabs voice cloning, updating/upgrading multix, or asks for batch media optimization.
4
- version: 0.7.3 # x-release-please-version
4
+ version: 0.9.0 # x-release-please-version
5
5
  ---
6
6
 
7
7
  # multix — AI Multimodal CLI
@@ -35,7 +35,7 @@ Detects whether `multix` was installed via npm, pnpm, yarn, or bun global instal
35
35
  ## Required env (set at least one provider key)
36
36
 
37
37
  ```
38
- GEMINI_API_KEY # Google AI Studio
38
+ GEMINI_API_KEY # Google AI Studio (extra keys: GEMINI_API_KEY_2, _3, … rotate on 429/RESOURCE_EXHAUSTED)
39
39
  OPENAI_API_KEY # OpenAI API (image, image edit, TTS, STT)
40
40
  OPENROUTER_API_KEY # OpenRouter (multi-model gateway: image + video via Veo, Seedance, Kling)
41
41
  MINIMAX_API_KEY # MiniMax (image/video/speech/music)
@@ -48,7 +48,7 @@ CLOUDFLARE_API_TOKEN # Cloudflare Workers AI / AI Gateway token
48
48
  # Video additionally requires CLOUDFLARE_AI_GATEWAY_ID and REPLICATE_API_TOKEN.
49
49
  ```
50
50
 
51
- Priority: `process.env` > `cwd/.env` > `~/.multix/.env`.
51
+ Priority: `process.env` > `cwd/.env` > `~/.multix/.env`. Set `MULTIX_LEGACY_CLAUDE_ENV=1` to also read legacy AgentKit `.claude` env files (credential names only, lowest priority); `multix check -v` lists loaded env files.
52
52
 
53
53
  ## Gemini text-output tasks
54
54
 
@@ -323,7 +323,8 @@ Requires `cwebp` (libwebp: `brew install webp`, `apt install webp`) on `PATH`.
323
323
  | `CLOUDFLARE_AI_GATEWAY_COLLECT_LOG_PAYLOAD` | Set `true` to explicitly opt in to AI Gateway request-payload collection; defaults to `false` |
324
324
  | `REPLICATE_API_TOKEN` | Required for Cloudflare AI Gateway video |
325
325
  | `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 |
326
- | `MULTIX_DISABLE_HOME_ENV` | `1` to skip `~/.multix/.env` |
326
+ | `MULTIX_DISABLE_HOME_ENV` | `1` to skip every home-directory env file (`~/.multix/.env`, `~/.claude/...`) |
327
+ | `MULTIX_LEGACY_CLAUDE_ENV` | `1` to also read legacy `.claude` env files (credential names only) |
327
328
 
328
329
  ## Quick examples for agents
329
330