@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 +14 -0
- package/README.md +6 -1
- package/dist/cli.js +342 -151
- package/package.json +1 -1
- package/skill/SKILL.md +5 -4
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
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
if (
|
|
293
|
-
|
|
294
|
-
|
|
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
|
|
3151
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
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
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
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
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
|
|
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
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
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
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
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(
|
|
3948
|
-
const
|
|
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>", "
|
|
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
|
-
|
|
3971
|
-
|
|
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
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
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
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
|
|
4272
|
-
|
|
4273
|
-
|
|
4274
|
-
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
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
|
|
7040
|
-
|
|
7041
|
-
|
|
7042
|
-
|
|
7043
|
-
|
|
7044
|
-
|
|
7045
|
-
|
|
7046
|
-
|
|
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
|
|
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 (!
|
|
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
|
-
|
|
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
|
|
7114
|
-
|
|
7115
|
-
|
|
7116
|
-
|
|
7117
|
-
|
|
7118
|
-
|
|
7119
|
-
|
|
7120
|
-
|
|
7121
|
-
|
|
7122
|
-
|
|
7123
|
-
|
|
7124
|
-
|
|
7125
|
-
|
|
7126
|
-
|
|
7127
|
-
|
|
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
|
-
|
|
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
|
|
7407
|
+
import fs47 from "fs";
|
|
7217
7408
|
import path48 from "path";
|
|
7218
7409
|
|
|
7219
7410
|
// src/commands/media/batch.ts
|
|
7220
|
-
import
|
|
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
|
-
|
|
7418
|
-
const entries =
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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 (!
|
|
7520
|
-
|
|
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 (!
|
|
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 (!
|
|
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
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.
|
|
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
|
|