Skip to content

Commit 855ce77

Browse files
committed
Merge branch 'main' into fix/image-download-https
Resolve conflict in src/commands/image/generate.ts: keep both the base64 response format support (PR #94) and the file overwrite warning (main).
2 parents 9a2bdab + 527e06d commit 855ce77

29 files changed

Lines changed: 816 additions & 132 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
</p>
1313

1414
<p align="center">
15-
<a href="README_CN.md">中文文档</a> · <a href="https://platform.minimax.io">Global Platform</a> · <a href="https://platform.minimaxi.com">CN Platform</a>
15+
<a href="README_CN.md">中文文档</a> · <a href="https://platform.minimax.io">Global Platform</a> · <a href="https://platform.minimaxi.com">CN Platform</a> · <a href="https://platform.minimax.io/docs/token-plan/minimax-cli">Example</a>
1616
</p>
1717

1818
## Features

README_CN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
</p>
1313

1414
<p align="center">
15-
<a href="README.md">English</a> · <a href="https://platform.minimax.io">国际版平台</a> · <a href="https://platform.minimaxi.com">国内版平台</a>
15+
<a href="README.md">English</a> · <a href="https://platform.minimax.io">国际版平台</a> · <a href="https://platform.minimaxi.com">国内版平台</a> · <a href="https://platform.minimaxi.com/docs/token-plan/minimax-cli">例子</a>
1616
</p>
1717

1818
## 功能特性

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "mmx-cli",
3-
"version": "1.0.7",
3+
"version": "1.0.11",
44
"description": "CLI for the MiniMax AI Platform",
55
"type": "module",
66
"engines": {

skill/SKILL.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ mmx speech synthesize --text <text> [flags]
178178
| `--bitrate <bps>` | number | Bitrate (default: 128000) |
179179
| `--channels <n>` | number | Audio channels (default: 1) |
180180
| `--language <code>` | string | Language boost |
181-
| `--subtitles` | boolean | Include subtitle timing data |
181+
| `--subtitles` | boolean | Download and save subtitles as `.srt` file (alongside `--out` audio file). API must support subtitles for the selected model.
182182
| `--pronunciation <from/to>` | string, repeatable | Custom pronunciation |
183183
| `--sound-effect <effect>` | string | Add sound effect |
184184
| `--out <path>` | string | Save audio to file |
@@ -188,6 +188,9 @@ mmx speech synthesize --text <text> [flags]
188188
mmx speech synthesize --text "Hello world" --out hello.mp3 --quiet
189189
# stdout: hello.mp3
190190

191+
mmx speech synthesize --text "Hello" --subtitles --out hello.mp3
192+
# saves hello.mp3 + hello.srt (SRT subtitle file)
193+
191194
echo "Breaking news." | mmx speech synthesize --text-file - --out news.mp3
192195
```
193196

src/auth/oauth.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,21 @@ export async function startBrowserFlow(
4444

4545
const authUrl = `${config.authorizationUrl}?${params}`;
4646

47-
// Open browser
48-
const { exec } = await import('child_process');
47+
// Open browser using execFile/spawn instead of exec to prevent shell injection.
48+
// exec() passes the string to a shell, so a crafted authUrl containing shell
49+
// metacharacters (e.g. from a malicious authorization server redirect) could
50+
// execute arbitrary commands. execFile/spawn bypass the shell entirely. (#79)
51+
const { execFile, spawn } = await import('child_process');
4952
const platform = process.platform;
50-
const openCmd = platform === 'darwin' ? 'open' :
51-
platform === 'win32' ? 'start' : 'xdg-open';
5253

53-
exec(`${openCmd} "${authUrl}"`);
54+
if (platform === 'darwin') {
55+
execFile('open', [authUrl]);
56+
} else if (platform === 'win32') {
57+
// On Windows, 'start' is a shell built-in — use cmd.exe /c start explicitly.
58+
spawn('cmd.exe', ['/c', 'start', '', authUrl], { shell: false, detached: true });
59+
} else {
60+
execFile('xdg-open', [authUrl]);
61+
}
5462
process.stderr.write('Opening browser to authenticate with MiniMax...\n');
5563

5664
// Start local server to receive callback

src/auth/refresh.ts

Lines changed: 56 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -6,45 +6,68 @@ import { ExitCode } from "../errors/codes";
66
// OAuth config — endpoints TBD pending MiniMax OAuth documentation
77
const TOKEN_URL = "https://api.minimax.io/v1/oauth/token";
88

9+
const MAX_REFRESH_RETRIES = 2;
10+
const RETRY_DELAY_MS = 500;
11+
912
export async function refreshAccessToken(
1013
refreshToken: string,
1114
): Promise<OAuthTokens> {
12-
let res: Response;
13-
try {
14-
res = await fetch(TOKEN_URL, {
15-
method: "POST",
16-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
17-
body: new URLSearchParams({
18-
grant_type: "refresh_token",
19-
refresh_token: refreshToken,
20-
}),
21-
signal: AbortSignal.timeout(10_000),
22-
});
23-
} catch (err) {
24-
const isTimeout =
25-
err instanceof Error &&
26-
(err.name === "AbortError" ||
27-
err.name === "TimeoutError" ||
28-
err.message.includes("timed out"));
29-
throw new CLIError(
30-
isTimeout
31-
? "Token refresh timed out — auth server did not respond within 10 s."
32-
: `Token refresh failed: ${err instanceof Error ? err.message : String(err)}`,
33-
ExitCode.AUTH,
34-
"Check your network connection.\nRe-authenticate: mmx auth login",
35-
);
36-
}
15+
let lastErr: Error | null = null;
16+
17+
for (let attempt = 0; attempt <= MAX_REFRESH_RETRIES; attempt++) {
18+
if (attempt > 0) {
19+
// Exponential backoff before retry
20+
await new Promise(r => setTimeout(r, RETRY_DELAY_MS * attempt));
21+
}
22+
23+
let res: Response;
24+
try {
25+
res = await fetch(TOKEN_URL, {
26+
method: "POST",
27+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
28+
body: new URLSearchParams({
29+
grant_type: "refresh_token",
30+
refresh_token: refreshToken,
31+
}),
32+
signal: AbortSignal.timeout(10_000),
33+
});
34+
} catch (err) {
35+
const isTimeout =
36+
err instanceof Error &&
37+
(err.name === "AbortError" ||
38+
err.name === "TimeoutError" ||
39+
err.message.includes("timed out"));
40+
lastErr = new Error(
41+
isTimeout
42+
? "Token refresh timed out — auth server did not respond within 10 s."
43+
: `Token refresh failed: ${err instanceof Error ? err.message : String(err)}`,
44+
);
45+
continue; // retry
46+
}
47+
48+
if (!res.ok) {
49+
// 4xx errors won't recover with retry
50+
if (res.status >= 400 && res.status < 500) {
51+
throw new CLIError(
52+
"OAuth session expired and could not be refreshed.",
53+
ExitCode.AUTH,
54+
"Re-authenticate: mmx auth login",
55+
);
56+
}
57+
lastErr = new Error(`Token refresh failed: HTTP ${res.status}`);
58+
continue; // retry 5xx errors
59+
}
3760

38-
if (!res.ok) {
39-
throw new CLIError(
40-
"OAuth session expired and could not be refreshed.",
41-
ExitCode.AUTH,
42-
"Re-authenticate: mmx auth login",
43-
);
61+
const data = (await res.json()) as OAuthTokens;
62+
return data;
4463
}
4564

46-
const data = (await res.json()) as OAuthTokens;
47-
return data;
65+
// All retries exhausted
66+
throw new CLIError(
67+
`Token refresh failed after ${MAX_REFRESH_RETRIES + 1} attempts: ${lastErr?.message}`,
68+
ExitCode.AUTH,
69+
"Check your network connection.\nRe-authenticate: mmx auth login",
70+
);
4871
}
4972

5073
export async function ensureFreshToken(creds: CredentialFile): Promise<string> {

src/client/endpoints.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export function vlmEndpoint(baseUrl: string): string {
4141
export function quotaEndpoint(baseUrl: string): string {
4242
// Quota endpoint uses api subdomain
4343
const host = baseUrl.includes('minimaxi.com') ? 'https://api.minimaxi.com' : 'https://api.minimax.io';
44-
return `${host}/v1/api/openplatform/coding_plan/remains`;
44+
return `${host}/v1/token_plan/remains`;
4545
}
4646

4747
export function fileUploadEndpoint(baseUrl: string): string {

src/commands/config/set.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,9 @@ export default defineCommand({
3030
'mmx config set --key base_url --value https://api-uw.minimax.io',
3131
],
3232
async run(config: Config, flags: GlobalFlags) {
33-
const key = flags.key as string | undefined;
34-
const value = flags.value as string | undefined;
33+
const positional = flags._positional as string[] | undefined;
34+
const key = (flags.key as string | undefined) ?? positional?.[0];
35+
const value = (flags.value as string | undefined) ?? positional?.[1];
3536

3637
if (!key || value === undefined) {
3738
throw new CLIError(
@@ -85,6 +86,12 @@ export default defineCommand({
8586

8687
const existing = readConfigFile() as Record<string, unknown>;
8788
existing[resolvedKey] = resolvedKey === 'timeout' ? Number(value) : value;
89+
90+
// When API key changes, clear cached region so it gets re-detected
91+
if (resolvedKey === 'api_key') {
92+
delete existing.region;
93+
}
94+
8895
await writeConfigFile(existing);
8996

9097
if (!config.quiet) {

src/commands/image/generate.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,9 @@ export default defineCommand({
164164
for (let i = 0; i < images.length; i++) {
165165
const filename = `${prefix}_${String(i + 1).padStart(3, '0')}.jpg`;
166166
const destPath = join(outDir, filename);
167+
if (existsSync(destPath)) {
168+
process.stderr.write(`Warning: overwriting existing file: ${destPath}\n`);
169+
}
167170
writeFileSync(destPath, images[i]!, 'base64');
168171
saved.push(destPath);
169172
}
@@ -172,12 +175,24 @@ export default defineCommand({
172175
for (let i = 0; i < imageUrls.length; i++) {
173176
const filename = `${prefix}_${String(i + 1).padStart(3, '0')}.jpg`;
174177
const destPath = join(outDir, filename);
178+
if (existsSync(destPath)) {
179+
process.stderr.write(`Warning: overwriting existing file: ${destPath}\n`);
180+
}
175181
await downloadFile(imageUrls[i]!, destPath, { quiet: config.quiet });
176182
saved.push(destPath);
177183
}
178184
}
179185

180-
if (config.quiet) {
186+
// --output json is respected even in --quiet mode (JSON is the actual output, not progress)
187+
if (format === 'json') {
188+
console.log(formatOutput({
189+
id: response.data.task_id,
190+
saved,
191+
success_count: response.data.success_count,
192+
failed_count: response.data.failed_count,
193+
}, format));
194+
} else if (config.quiet) {
195+
// Non-JSON quiet mode: just print file paths
181196
console.log(saved.join('\n'));
182197
} else {
183198
console.log(formatOutput({

src/commands/search/query.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export default defineCommand({
3030
'mmx search query --q "latest news" --output json',
3131
],
3232
async run(config: Config, flags: GlobalFlags) {
33-
const query = (flags.q ?? (flags._positional as string[]|undefined)?.[0]) as string | undefined;
33+
const query = (flags.q ?? flags.query ?? (flags._positional as string[]|undefined)?.[0]) as string | undefined;
3434

3535
if (!query) {
3636
throw new CLIError(

0 commit comments

Comments
 (0)