Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"scripts": {
"dev": "bun run src/main.ts",
"build": "bun run build.ts",
"build:dev": "bun build src/main.ts --compile --minify --outfile dist/minimax --define \"process.env.CLI_VERSION='$(node -p \"require('./package.json').version\")'\"",
"lint": "eslint src/ test/",
"typecheck": "tsc --noEmit",
"test": "bun test",
Expand Down
117 changes: 71 additions & 46 deletions src/args.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,65 @@
import type { GlobalFlags } from './types/flags';
import type { OptionDef } from './command';

export interface ParsedArgs {
commandPath: string[];
flags: GlobalFlags;
function kebabToCamel(str: string): string {
return str.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
}

/** Extract camelCase flag name from an OptionDef.flag string, e.g. '--max-tokens <n>' → 'maxTokens' */
function flagKey(def: OptionDef): string | null {
const m = def.flag.match(/^--([a-z][a-z0-9-]*)/i);
return m ? kebabToCamel(m[1]!) : null;
}

/** Boolean when no value placeholder and type is not string/number/array */
function isBooleanDef(def: OptionDef): boolean {
if (def.type === 'boolean') return true;
if (def.type === 'string' || def.type === 'number' || def.type === 'array') return false;
return !def.flag.includes('<') && !def.flag.includes('[');
}

export function parseArgs(argv: string[]): ParsedArgs {
const commandPath: string[] = [];
interface FlagSchema {
booleans: Set<string>;
numbers: Set<string>;
arrays: Set<string>;
}

function buildSchema(options: OptionDef[]): FlagSchema {
const booleans = new Set<string>();
const numbers = new Set<string>();
const arrays = new Set<string>();
for (const opt of options) {
const key = flagKey(opt);
if (!key) continue;
if (isBooleanDef(opt)) booleans.add(key);
else if (opt.type === 'number') numbers.add(key);
else if (opt.type === 'array') arrays.add(key);
}
return { booleans, numbers, arrays };
}

/**
* Quick scan: collect positional (non-dash) args to determine the command path.
* Does not consume flag values — just skips dash-prefixed tokens.
*/
export function scanCommandPath(argv: string[]): string[] {
const path: string[] = [];
for (const arg of argv) {
if (arg === '--') break;
if (!arg.startsWith('-')) path.push(arg);
}
return path;
}

/**
* Full flag parse. Types are derived entirely from the provided OptionDef schema:
* - boolean: no <value> placeholder in flag string (or type: 'boolean')
* - number: type: 'number'
* - array: type: 'array' (repeatable via multiple --flag occurrences)
* - default: string
*/
export function parseFlags(argv: string[], options: OptionDef[]): GlobalFlags {
const schema = buildSchema(options);
const flags: GlobalFlags = {
quiet: false,
verbose: false,
Expand All @@ -22,77 +75,49 @@ export function parseArgs(argv: string[]): ParsedArgs {
while (i < argv.length) {
const arg = argv[i]!;

if (arg === '--help' || arg === '-h') {
flags.help = true;
i++;
continue;
}

if (arg === '--') {
i++;
break;
}
if (arg === '--help' || arg === '-h') { flags.help = true; i++; continue; }
if (arg === '--') { i++; break; }

if (arg.startsWith('--')) {
const eqIndex = arg.indexOf('=');
const eqIdx = arg.indexOf('=');
let key: string;
let value: string | undefined;

if (eqIndex !== -1) {
key = arg.slice(2, eqIndex);
value = arg.slice(eqIndex + 1);
if (eqIdx !== -1) {
key = arg.slice(2, eqIdx);
value = arg.slice(eqIdx + 1);
} else {
key = arg.slice(2);
}

const camelKey = kebabToCamel(key);

// Boolean flags
if (['quiet', 'verbose', 'noColor', 'yes', 'dryRun', 'help', 'stream',
'subtitles', 'wait', 'noWait', 'noBrowser',
'nonInteractive', 'async'].includes(camelKey)) {
if (schema.booleans.has(camelKey)) {
(flags as Record<string, unknown>)[camelKey] = true;
i++;
continue;
}

// Value flags
if (value === undefined) {
i++;
value = argv[i];
}

if (value === undefined) {
throw new Error(`Flag --${key} requires a value.`);
}
if (value === undefined) throw new Error(`Flag --${key} requires a value.`);

// Repeatable flags
if (['message', 'tool', 'pronunciation'].includes(camelKey)) {
if (schema.arrays.has(camelKey)) {
const arr = (flags as Record<string, unknown>)[camelKey] as string[] | undefined;
if (arr) {
arr.push(value);
} else {
(flags as Record<string, unknown>)[camelKey] = [value];
}
} else if (['maxTokens', 'temperature', 'topP', 'speed', 'volume',
'pitch', 'sampleRate', 'bitrate', 'channels', 'n',
'timeout', 'pollInterval'].includes(camelKey)) {
if (arr) arr.push(value);
else (flags as Record<string, unknown>)[camelKey] = [value];
} else if (schema.numbers.has(camelKey)) {
(flags as Record<string, unknown>)[camelKey] = Number(value);
} else {
(flags as Record<string, unknown>)[camelKey] = value;
}
i++;
continue;
}

// Positional argument — part of command path
commandPath.push(arg);
i++;
}

return { commandPath, flags };
}

function kebabToCamel(str: string): string {
return str.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
return flags;
}
72 changes: 15 additions & 57 deletions src/client/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ import type { Config } from '../config/schema';
import type { ApiErrorBody } from '../errors/api';
import { resolveCredential } from '../auth/resolver';
import { mapApiError } from '../errors/api';
import { CLIError } from '../errors/base';
import { ExitCode } from '../errors/codes';
import { maybeShowStatusBar } from '../output/status-bar';

export interface RequestOpts {
url: string;
Expand All @@ -16,81 +15,45 @@ export interface RequestOpts {
authStyle?: 'bearer' | 'x-api-key';
}

// Printed once per process invocation to avoid repeating on every request.
let statusBarPrinted = false;

export async function request(
config: Config,
opts: RequestOpts,
): Promise<Response> {
const isFormData =
typeof FormData !== 'undefined' && opts.body instanceof FormData;
export async function request(config: Config, opts: RequestOpts): Promise<Response> {
const isFormData = typeof FormData !== 'undefined' && opts.body instanceof FormData;

const version = process.env.CLI_VERSION ?? '0.3.1';
const headers: Record<string, string> = {
'User-Agent': `minimax-cli/${version}`,
...opts.headers,
};

// Only set Content-Type for non-FormData bodies; FormData lets fetch set the multipart boundary automatically
if (!isFormData && !headers['Content-Type']) {
headers['Content-Type'] = 'application/json';
}

if (!opts.noAuth) {
const credential = await resolveCredential(config);

if (opts.authStyle === 'x-api-key') {
headers['x-api-key'] = credential.token;
} else {
headers['Authorization'] = `Bearer ${credential.token}`;
}

if (config.verbose) {
process.stderr.write(`> ${opts.method || 'GET'} ${opts.url}\n`);
process.stderr.write(`> ${opts.method ?? 'GET'} ${opts.url}\n`);
process.stderr.write(`> Auth: ${credential.token.slice(0, 8)}...\n`);
}

// ANSI 真彩色 (24-bit) 与基础排版
if (!config.quiet && !statusBarPrinted && process.stderr.isTTY) {
statusBarPrinted = true;
const reset = '\x1b[0m';
const dim = '\x1b[2m';
const bold = '\x1b[1m'; // 新增加粗效果

// 从 MiniMax Logo/品牌视觉提取的 RGB 颜色
const mmBlue = '\x1b[38;2;43;82;255m'; // 主品牌色:MiniMax 科技蓝 (#2B52FF)
const mmPurple = '\x1b[38;2;147;51;234m'; // 辅助品牌色:活力紫 (#9333EA)
const mmCyan = '\x1b[38;2;6;184;212m'; // 点缀色:青色 (#06B8D4)
const mmPink = '\x1b[38;2;236;72;153m'; // 点缀色:粉红 (#EC4899)

// 提取 Region (根据 baseUrl 推断)
const region = config.baseUrl.includes('minimaxi.com') ? 'CN' : 'Global';

// 提取脱敏的 Key
const token = credential.token;
const maskedKey = token.length > 8 ? `${token.slice(0, 4)}...${token.slice(-4)}` : '***';

// 尝试从 body 中提取 Model
let modelStr = '';
if (opts.body && typeof opts.body === 'object' && 'model' in opts.body) {
modelStr = ` ${dim}|${reset} ${dim}Model:${reset} ${mmPurple}${(opts.body as any).model}${reset}`;
}

// 打印带有完整 MINIMAX 标识的状态栏
process.stderr.write(
`${bold}${mmBlue}MINIMAX${reset} ` +
`${dim}Region:${reset} ${mmCyan}${region}${reset} ` +
`${dim}|${reset} ` +
`${dim}Key:${reset} ${mmPink}${maskedKey}${reset}` +
`${modelStr}\n`
);
}
const model =
opts.body && typeof opts.body === 'object' && 'model' in opts.body
? String((opts.body as Record<string, unknown>).model)
: undefined;

maybeShowStatusBar(config, credential.token, model);
}

const timeoutMs = (opts.timeout || config.timeout) * 1000;
const timeoutMs = (opts.timeout ?? config.timeout) * 1000;

const res = await fetch(opts.url, {
method: opts.method || 'GET',
method: opts.method ?? 'GET',
headers,
body: opts.body
? isFormData
Expand All @@ -106,11 +69,7 @@ export async function request(

if (!res.ok) {
let body: ApiErrorBody = {};
try {
body = (await res.json()) as ApiErrorBody;
} catch {
// Response body is not JSON
}
try { body = (await res.json()) as ApiErrorBody; } catch { /* non-JSON */ }
throw mapApiError(res.status, body, opts.url);
}

Expand All @@ -121,8 +80,7 @@ export async function requestJson<T>(config: Config, opts: RequestOpts): Promise
const res = await request(config, opts);
const data = (await res.json()) as T & { base_resp?: { status_code?: number; status_msg?: string } };

// MiniMax APIs return HTTP 200 with error details in base_resp
if (data.base_resp && data.base_resp.status_code && data.base_resp.status_code !== 0) {
if (data.base_resp?.status_code && data.base_resp.status_code !== 0) {
throw mapApiError(200, { base_resp: data.base_resp }, opts.url);
}

Expand Down
18 changes: 18 additions & 0 deletions src/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,21 @@ export function defineCommand(spec: CommandSpec): Command {
execute: spec.run,
};
}

/** Global flags shared by all commands — drives the parser's type resolution. */
export const GLOBAL_OPTIONS: OptionDef[] = [
{ flag: '--api-key <key>', description: 'API key' },
{ flag: '--region <region>', description: 'API region: global, cn' },
{ flag: '--base-url <url>', description: 'API base URL' },
{ flag: '--output <format>', description: 'Output format: text, json, yaml' },
{ flag: '--timeout <seconds>', description: 'Request timeout', type: 'number' },
{ flag: '--quiet', description: 'Suppress non-essential output' },
{ flag: '--verbose', description: 'Print HTTP request/response details' },
{ flag: '--no-color', description: 'Disable ANSI colors' },
{ flag: '--yes', description: 'Skip confirmation prompts' },
{ flag: '--dry-run', description: 'Dry run mode' },
{ flag: '--non-interactive', description: 'Disable interactive prompts' },
{ flag: '--async', description: 'Return task ID immediately' },
{ flag: '--help', description: 'Show help' },
{ flag: '--version', description: 'Print version' },
];
2 changes: 1 addition & 1 deletion src/commands/image/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export default defineCommand({
options: [
{ flag: '--prompt <text>', description: 'Image description', required: true },
{ flag: '--aspect-ratio <ratio>', description: 'Aspect ratio (e.g. 16:9, 1:1)' },
{ flag: '--n <count>', description: 'Number of images to generate (default: 1)' },
{ flag: '--n <count>', description: 'Number of images to generate (default: 1)', type: 'number' },
{ flag: '--subject-ref <params>', description: 'Subject reference (type=character,image=path)' },
{ flag: '--out-dir <dir>', description: 'Download images to directory' },
{ flag: '--out-prefix <prefix>', description: 'Filename prefix (default: image)' },
Expand Down
4 changes: 2 additions & 2 deletions src/commands/music/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ export default defineCommand({
{ flag: '--lyrics <text>', description: 'Song lyrics' },
{ flag: '--lyrics-file <path>', description: 'Read lyrics from file (use - for stdin)' },
{ flag: '--format <fmt>', description: 'Audio format (default: mp3)' },
{ flag: '--sample-rate <hz>', description: 'Sample rate (default: 44100)' },
{ flag: '--bitrate <bps>', description: 'Bitrate (default: 256000)' },
{ flag: '--sample-rate <hz>', description: 'Sample rate (default: 44100)', type: 'number' },
{ flag: '--bitrate <bps>', description: 'Bitrate (default: 256000)', type: 'number' },
{ flag: '--stream', description: 'Stream raw audio to stdout' },
{ flag: '--out <path>', description: 'Save audio to file (uses hex decoding)' },
],
Expand Down
34 changes: 17 additions & 17 deletions src/commands/speech/synthesize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,23 @@ export default defineCommand({
description: 'Synchronous TTS, up to 10k chars (speech-2.8-hd / 2.6 / 02)',
usage: 'minimax speech synthesize --text <text> [--out <path>] [flags]',
options: [
{ flag: '--model <model>', description: 'Model ID (default: speech-2.8-hd)' },
{ flag: '--text <text>', description: 'Text to synthesize' },
{ flag: '--text-file <path>', description: 'Read text from file (use - for stdin)' },
{ flag: '--voice <id>', description: 'Voice ID (default: English_expressive_narrator)' },
{ flag: '--speed <n>', description: 'Speech speed multiplier' },
{ flag: '--volume <n>', description: 'Volume level' },
{ flag: '--pitch <n>', description: 'Pitch adjustment' },
{ flag: '--format <fmt>', description: 'Audio format (default: mp3)' },
{ flag: '--sample-rate <hz>', description: 'Sample rate (default: 32000)' },
{ flag: '--bitrate <bps>', description: 'Bitrate (default: 128000)' },
{ flag: '--channels <n>', description: 'Audio channels (default: 1)' },
{ flag: '--language <code>', description: 'Language boost' },
{ flag: '--subtitles', description: 'Include subtitle timing data' },
{ flag: '--pronunciation <from/to>', description: 'Custom pronunciation (repeatable)' },
{ flag: '--sound-effect <effect>', description: 'Add sound effect' },
{ flag: '--out <path>', description: 'Save audio to file (uses hex decoding)' },
{ flag: '--stream', description: 'Stream raw audio to stdout' },
{ flag: '--model <model>', description: 'Model ID (default: speech-2.8-hd)' },
{ flag: '--text <text>', description: 'Text to synthesize' },
{ flag: '--text-file <path>', description: 'Read text from file (use - for stdin)' },
{ flag: '--voice <id>', description: 'Voice ID (default: English_expressive_narrator)' },
{ flag: '--speed <n>', description: 'Speech speed multiplier', type: 'number' },
{ flag: '--volume <n>', description: 'Volume level', type: 'number' },
{ flag: '--pitch <n>', description: 'Pitch adjustment', type: 'number' },
{ flag: '--format <fmt>', description: 'Audio format (default: mp3)' },
{ flag: '--sample-rate <hz>', description: 'Sample rate (default: 32000)', type: 'number' },
{ flag: '--bitrate <bps>', description: 'Bitrate (default: 128000)', type: 'number' },
{ flag: '--channels <n>', description: 'Audio channels (default: 1)', type: 'number' },
{ flag: '--language <code>', description: 'Language boost' },
{ flag: '--subtitles', description: 'Include subtitle timing data' },
{ flag: '--pronunciation <from/to>', description: 'Custom pronunciation (repeatable)', type: 'array' },
{ flag: '--sound-effect <effect>', description: 'Add sound effect' },
{ flag: '--out <path>', description: 'Save audio to file (uses hex decoding)' },
{ flag: '--stream', description: 'Stream raw audio to stdout' },
],
examples: [
'minimax speech synthesize --text "Hello, world!"',
Expand Down
Loading
Loading