Skip to content

Commit f3265f8

Browse files
authored
Merge pull request #12 from MiniMax-AI-Dev/refactor/arch-cleanup
refactor: decouple flag parsing, extract status-bar, cleanup dead code
2 parents 59b5fa9 + 22dd7ac commit f3265f8

12 files changed

Lines changed: 175 additions & 153 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"scripts": {
1414
"dev": "bun run src/main.ts",
1515
"build": "bun run build.ts",
16+
"build:dev": "bun build src/main.ts --compile --minify --outfile dist/minimax --define \"process.env.CLI_VERSION='$(node -p \"require('./package.json').version\")'\"",
1617
"lint": "eslint src/ test/",
1718
"typecheck": "tsc --noEmit",
1819
"test": "bun test",

src/args.ts

Lines changed: 71 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,65 @@
11
import type { GlobalFlags } from './types/flags';
2+
import type { OptionDef } from './command';
23

3-
export interface ParsedArgs {
4-
commandPath: string[];
5-
flags: GlobalFlags;
4+
function kebabToCamel(str: string): string {
5+
return str.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
6+
}
7+
8+
/** Extract camelCase flag name from an OptionDef.flag string, e.g. '--max-tokens <n>' → 'maxTokens' */
9+
function flagKey(def: OptionDef): string | null {
10+
const m = def.flag.match(/^--([a-z][a-z0-9-]*)/i);
11+
return m ? kebabToCamel(m[1]!) : null;
12+
}
13+
14+
/** Boolean when no value placeholder and type is not string/number/array */
15+
function isBooleanDef(def: OptionDef): boolean {
16+
if (def.type === 'boolean') return true;
17+
if (def.type === 'string' || def.type === 'number' || def.type === 'array') return false;
18+
return !def.flag.includes('<') && !def.flag.includes('[');
619
}
720

8-
export function parseArgs(argv: string[]): ParsedArgs {
9-
const commandPath: string[] = [];
21+
interface FlagSchema {
22+
booleans: Set<string>;
23+
numbers: Set<string>;
24+
arrays: Set<string>;
25+
}
26+
27+
function buildSchema(options: OptionDef[]): FlagSchema {
28+
const booleans = new Set<string>();
29+
const numbers = new Set<string>();
30+
const arrays = new Set<string>();
31+
for (const opt of options) {
32+
const key = flagKey(opt);
33+
if (!key) continue;
34+
if (isBooleanDef(opt)) booleans.add(key);
35+
else if (opt.type === 'number') numbers.add(key);
36+
else if (opt.type === 'array') arrays.add(key);
37+
}
38+
return { booleans, numbers, arrays };
39+
}
40+
41+
/**
42+
* Quick scan: collect positional (non-dash) args to determine the command path.
43+
* Does not consume flag values — just skips dash-prefixed tokens.
44+
*/
45+
export function scanCommandPath(argv: string[]): string[] {
46+
const path: string[] = [];
47+
for (const arg of argv) {
48+
if (arg === '--') break;
49+
if (!arg.startsWith('-')) path.push(arg);
50+
}
51+
return path;
52+
}
53+
54+
/**
55+
* Full flag parse. Types are derived entirely from the provided OptionDef schema:
56+
* - boolean: no <value> placeholder in flag string (or type: 'boolean')
57+
* - number: type: 'number'
58+
* - array: type: 'array' (repeatable via multiple --flag occurrences)
59+
* - default: string
60+
*/
61+
export function parseFlags(argv: string[], options: OptionDef[]): GlobalFlags {
62+
const schema = buildSchema(options);
1063
const flags: GlobalFlags = {
1164
quiet: false,
1265
verbose: false,
@@ -22,77 +75,49 @@ export function parseArgs(argv: string[]): ParsedArgs {
2275
while (i < argv.length) {
2376
const arg = argv[i]!;
2477

25-
if (arg === '--help' || arg === '-h') {
26-
flags.help = true;
27-
i++;
28-
continue;
29-
}
30-
31-
if (arg === '--') {
32-
i++;
33-
break;
34-
}
78+
if (arg === '--help' || arg === '-h') { flags.help = true; i++; continue; }
79+
if (arg === '--') { i++; break; }
3580

3681
if (arg.startsWith('--')) {
37-
const eqIndex = arg.indexOf('=');
82+
const eqIdx = arg.indexOf('=');
3883
let key: string;
3984
let value: string | undefined;
4085

41-
if (eqIndex !== -1) {
42-
key = arg.slice(2, eqIndex);
43-
value = arg.slice(eqIndex + 1);
86+
if (eqIdx !== -1) {
87+
key = arg.slice(2, eqIdx);
88+
value = arg.slice(eqIdx + 1);
4489
} else {
4590
key = arg.slice(2);
4691
}
4792

4893
const camelKey = kebabToCamel(key);
4994

50-
// Boolean flags
51-
if (['quiet', 'verbose', 'noColor', 'yes', 'dryRun', 'help', 'stream',
52-
'subtitles', 'wait', 'noWait', 'noBrowser',
53-
'nonInteractive', 'async'].includes(camelKey)) {
95+
if (schema.booleans.has(camelKey)) {
5496
(flags as Record<string, unknown>)[camelKey] = true;
5597
i++;
5698
continue;
5799
}
58100

59-
// Value flags
60101
if (value === undefined) {
61102
i++;
62103
value = argv[i];
63104
}
64105

65-
if (value === undefined) {
66-
throw new Error(`Flag --${key} requires a value.`);
67-
}
106+
if (value === undefined) throw new Error(`Flag --${key} requires a value.`);
68107

69-
// Repeatable flags
70-
if (['message', 'tool', 'pronunciation'].includes(camelKey)) {
108+
if (schema.arrays.has(camelKey)) {
71109
const arr = (flags as Record<string, unknown>)[camelKey] as string[] | undefined;
72-
if (arr) {
73-
arr.push(value);
74-
} else {
75-
(flags as Record<string, unknown>)[camelKey] = [value];
76-
}
77-
} else if (['maxTokens', 'temperature', 'topP', 'speed', 'volume',
78-
'pitch', 'sampleRate', 'bitrate', 'channels', 'n',
79-
'timeout', 'pollInterval'].includes(camelKey)) {
110+
if (arr) arr.push(value);
111+
else (flags as Record<string, unknown>)[camelKey] = [value];
112+
} else if (schema.numbers.has(camelKey)) {
80113
(flags as Record<string, unknown>)[camelKey] = Number(value);
81114
} else {
82115
(flags as Record<string, unknown>)[camelKey] = value;
83116
}
84-
i++;
85-
continue;
86117
}
87118

88-
// Positional argument — part of command path
89-
commandPath.push(arg);
90119
i++;
91120
}
92121

93-
return { commandPath, flags };
94-
}
95-
96-
function kebabToCamel(str: string): string {
97-
return str.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
122+
return flags;
98123
}

src/client/http.ts

Lines changed: 15 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@ import type { Config } from '../config/schema';
22
import type { ApiErrorBody } from '../errors/api';
33
import { resolveCredential } from '../auth/resolver';
44
import { mapApiError } from '../errors/api';
5-
import { CLIError } from '../errors/base';
6-
import { ExitCode } from '../errors/codes';
5+
import { maybeShowStatusBar } from '../output/status-bar';
76

87
export interface RequestOpts {
98
url: string;
@@ -16,81 +15,45 @@ export interface RequestOpts {
1615
authStyle?: 'bearer' | 'x-api-key';
1716
}
1817

19-
// Printed once per process invocation to avoid repeating on every request.
20-
let statusBarPrinted = false;
21-
22-
export async function request(
23-
config: Config,
24-
opts: RequestOpts,
25-
): Promise<Response> {
26-
const isFormData =
27-
typeof FormData !== 'undefined' && opts.body instanceof FormData;
18+
export async function request(config: Config, opts: RequestOpts): Promise<Response> {
19+
const isFormData = typeof FormData !== 'undefined' && opts.body instanceof FormData;
2820

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

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

4031
if (!opts.noAuth) {
4132
const credential = await resolveCredential(config);
33+
4234
if (opts.authStyle === 'x-api-key') {
4335
headers['x-api-key'] = credential.token;
4436
} else {
4537
headers['Authorization'] = `Bearer ${credential.token}`;
4638
}
4739

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

53-
// ANSI 真彩色 (24-bit) 与基础排版
54-
if (!config.quiet && !statusBarPrinted && process.stderr.isTTY) {
55-
statusBarPrinted = true;
56-
const reset = '\x1b[0m';
57-
const dim = '\x1b[2m';
58-
const bold = '\x1b[1m'; // 新增加粗效果
59-
60-
// 从 MiniMax Logo/品牌视觉提取的 RGB 颜色
61-
const mmBlue = '\x1b[38;2;43;82;255m'; // 主品牌色:MiniMax 科技蓝 (#2B52FF)
62-
const mmPurple = '\x1b[38;2;147;51;234m'; // 辅助品牌色:活力紫 (#9333EA)
63-
const mmCyan = '\x1b[38;2;6;184;212m'; // 点缀色:青色 (#06B8D4)
64-
const mmPink = '\x1b[38;2;236;72;153m'; // 点缀色:粉红 (#EC4899)
65-
66-
// 提取 Region (根据 baseUrl 推断)
67-
const region = config.baseUrl.includes('minimaxi.com') ? 'CN' : 'Global';
68-
69-
// 提取脱敏的 Key
70-
const token = credential.token;
71-
const maskedKey = token.length > 8 ? `${token.slice(0, 4)}...${token.slice(-4)}` : '***';
72-
73-
// 尝试从 body 中提取 Model
74-
let modelStr = '';
75-
if (opts.body && typeof opts.body === 'object' && 'model' in opts.body) {
76-
modelStr = ` ${dim}|${reset} ${dim}Model:${reset} ${mmPurple}${(opts.body as any).model}${reset}`;
77-
}
78-
79-
// 打印带有完整 MINIMAX 标识的状态栏
80-
process.stderr.write(
81-
`${bold}${mmBlue}MINIMAX${reset} ` +
82-
`${dim}Region:${reset} ${mmCyan}${region}${reset} ` +
83-
`${dim}|${reset} ` +
84-
`${dim}Key:${reset} ${mmPink}${maskedKey}${reset}` +
85-
`${modelStr}\n`
86-
);
87-
}
45+
const model =
46+
opts.body && typeof opts.body === 'object' && 'model' in opts.body
47+
? String((opts.body as Record<string, unknown>).model)
48+
: undefined;
49+
50+
maybeShowStatusBar(config, credential.token, model);
8851
}
8952

90-
const timeoutMs = (opts.timeout || config.timeout) * 1000;
53+
const timeoutMs = (opts.timeout ?? config.timeout) * 1000;
9154

9255
const res = await fetch(opts.url, {
93-
method: opts.method || 'GET',
56+
method: opts.method ?? 'GET',
9457
headers,
9558
body: opts.body
9659
? isFormData
@@ -106,11 +69,7 @@ export async function request(
10669

10770
if (!res.ok) {
10871
let body: ApiErrorBody = {};
109-
try {
110-
body = (await res.json()) as ApiErrorBody;
111-
} catch {
112-
// Response body is not JSON
113-
}
72+
try { body = (await res.json()) as ApiErrorBody; } catch { /* non-JSON */ }
11473
throw mapApiError(res.status, body, opts.url);
11574
}
11675

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

124-
// MiniMax APIs return HTTP 200 with error details in base_resp
125-
if (data.base_resp && data.base_resp.status_code && data.base_resp.status_code !== 0) {
83+
if (data.base_resp?.status_code && data.base_resp.status_code !== 0) {
12684
throw mapApiError(200, { base_resp: data.base_resp }, opts.url);
12785
}
12886

src/command.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,21 @@ export function defineCommand(spec: CommandSpec): Command {
3636
execute: spec.run,
3737
};
3838
}
39+
40+
/** Global flags shared by all commands — drives the parser's type resolution. */
41+
export const GLOBAL_OPTIONS: OptionDef[] = [
42+
{ flag: '--api-key <key>', description: 'API key' },
43+
{ flag: '--region <region>', description: 'API region: global, cn' },
44+
{ flag: '--base-url <url>', description: 'API base URL' },
45+
{ flag: '--output <format>', description: 'Output format: text, json, yaml' },
46+
{ flag: '--timeout <seconds>', description: 'Request timeout', type: 'number' },
47+
{ flag: '--quiet', description: 'Suppress non-essential output' },
48+
{ flag: '--verbose', description: 'Print HTTP request/response details' },
49+
{ flag: '--no-color', description: 'Disable ANSI colors' },
50+
{ flag: '--yes', description: 'Skip confirmation prompts' },
51+
{ flag: '--dry-run', description: 'Dry run mode' },
52+
{ flag: '--non-interactive', description: 'Disable interactive prompts' },
53+
{ flag: '--async', description: 'Return task ID immediately' },
54+
{ flag: '--help', description: 'Show help' },
55+
{ flag: '--version', description: 'Print version' },
56+
];

src/commands/image/generate.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export default defineCommand({
2020
options: [
2121
{ flag: '--prompt <text>', description: 'Image description', required: true },
2222
{ flag: '--aspect-ratio <ratio>', description: 'Aspect ratio (e.g. 16:9, 1:1)' },
23-
{ flag: '--n <count>', description: 'Number of images to generate (default: 1)' },
23+
{ flag: '--n <count>', description: 'Number of images to generate (default: 1)', type: 'number' },
2424
{ flag: '--subject-ref <params>', description: 'Subject reference (type=character,image=path)' },
2525
{ flag: '--out-dir <dir>', description: 'Download images to directory' },
2626
{ flag: '--out-prefix <prefix>', description: 'Filename prefix (default: image)' },

src/commands/music/generate.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ export default defineCommand({
1818
{ flag: '--lyrics <text>', description: 'Song lyrics' },
1919
{ flag: '--lyrics-file <path>', description: 'Read lyrics from file (use - for stdin)' },
2020
{ flag: '--format <fmt>', description: 'Audio format (default: mp3)' },
21-
{ flag: '--sample-rate <hz>', description: 'Sample rate (default: 44100)' },
22-
{ flag: '--bitrate <bps>', description: 'Bitrate (default: 256000)' },
21+
{ flag: '--sample-rate <hz>', description: 'Sample rate (default: 44100)', type: 'number' },
22+
{ flag: '--bitrate <bps>', description: 'Bitrate (default: 256000)', type: 'number' },
2323
{ flag: '--stream', description: 'Stream raw audio to stdout' },
2424
{ flag: '--out <path>', description: 'Save audio to file (uses hex decoding)' },
2525
],

src/commands/speech/synthesize.ts

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,23 +14,23 @@ export default defineCommand({
1414
description: 'Synchronous TTS, up to 10k chars (speech-2.8-hd / 2.6 / 02)',
1515
usage: 'minimax speech synthesize --text <text> [--out <path>] [flags]',
1616
options: [
17-
{ flag: '--model <model>', description: 'Model ID (default: speech-2.8-hd)' },
18-
{ flag: '--text <text>', description: 'Text to synthesize' },
19-
{ flag: '--text-file <path>', description: 'Read text from file (use - for stdin)' },
20-
{ flag: '--voice <id>', description: 'Voice ID (default: English_expressive_narrator)' },
21-
{ flag: '--speed <n>', description: 'Speech speed multiplier' },
22-
{ flag: '--volume <n>', description: 'Volume level' },
23-
{ flag: '--pitch <n>', description: 'Pitch adjustment' },
24-
{ flag: '--format <fmt>', description: 'Audio format (default: mp3)' },
25-
{ flag: '--sample-rate <hz>', description: 'Sample rate (default: 32000)' },
26-
{ flag: '--bitrate <bps>', description: 'Bitrate (default: 128000)' },
27-
{ flag: '--channels <n>', description: 'Audio channels (default: 1)' },
28-
{ flag: '--language <code>', description: 'Language boost' },
29-
{ flag: '--subtitles', description: 'Include subtitle timing data' },
30-
{ flag: '--pronunciation <from/to>', description: 'Custom pronunciation (repeatable)' },
31-
{ flag: '--sound-effect <effect>', description: 'Add sound effect' },
32-
{ flag: '--out <path>', description: 'Save audio to file (uses hex decoding)' },
33-
{ flag: '--stream', description: 'Stream raw audio to stdout' },
17+
{ flag: '--model <model>', description: 'Model ID (default: speech-2.8-hd)' },
18+
{ flag: '--text <text>', description: 'Text to synthesize' },
19+
{ flag: '--text-file <path>', description: 'Read text from file (use - for stdin)' },
20+
{ flag: '--voice <id>', description: 'Voice ID (default: English_expressive_narrator)' },
21+
{ flag: '--speed <n>', description: 'Speech speed multiplier', type: 'number' },
22+
{ flag: '--volume <n>', description: 'Volume level', type: 'number' },
23+
{ flag: '--pitch <n>', description: 'Pitch adjustment', type: 'number' },
24+
{ flag: '--format <fmt>', description: 'Audio format (default: mp3)' },
25+
{ flag: '--sample-rate <hz>', description: 'Sample rate (default: 32000)', type: 'number' },
26+
{ flag: '--bitrate <bps>', description: 'Bitrate (default: 128000)', type: 'number' },
27+
{ flag: '--channels <n>', description: 'Audio channels (default: 1)', type: 'number' },
28+
{ flag: '--language <code>', description: 'Language boost' },
29+
{ flag: '--subtitles', description: 'Include subtitle timing data' },
30+
{ flag: '--pronunciation <from/to>', description: 'Custom pronunciation (repeatable)', type: 'array' },
31+
{ flag: '--sound-effect <effect>', description: 'Add sound effect' },
32+
{ flag: '--out <path>', description: 'Save audio to file (uses hex decoding)' },
33+
{ flag: '--stream', description: 'Stream raw audio to stdout' },
3434
],
3535
examples: [
3636
'minimax speech synthesize --text "Hello, world!"',

0 commit comments

Comments
 (0)