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
29 changes: 20 additions & 9 deletions src/commands/config/set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,22 @@ import { readConfigFile, writeConfigFile } from '../../config/loader';
import type { Config } from '../../config/schema';
import type { GlobalFlags } from '../../types/flags';

const VALID_KEYS = ['region', 'base_url', 'output', 'timeout', 'api_key'];
const VALID_KEYS = ['region', 'base_url', 'output', 'timeout', 'api_key', 'default_text_model', 'default_speech_model', 'default_video_model', 'default_music_model'];

// Allow hyphen-style keys (e.g. default-text-model → default_text_model)
const KEY_ALIASES: Record<string, string> = {
'default-text-model': 'default_text_model',
'default-speech-model': 'default_speech_model',
'default-video-model': 'default_video_model',
'default-music-model': 'default_music_model',
};

export default defineCommand({
name: 'config set',
description: 'Set a config value',
usage: 'mmx config set --key <key> --value <value>',
options: [
{ flag: '--key <key>', description: 'Config key (region, base_url, output, timeout, api_key)' },
{ flag: '--key <key>', description: 'Config key (region, base_url, output, timeout, api_key, default_text_model, default_speech_model, default_video_model, default_music_model)' },
{ flag: '--value <value>', description: 'Value to set' },
],
examples: [
Expand All @@ -33,29 +41,32 @@ export default defineCommand({
);
}

if (!VALID_KEYS.includes(key)) {
// Resolve hyphen aliases to underscore keys
const resolvedKey: string = KEY_ALIASES[key] || key;

if (!VALID_KEYS.includes(resolvedKey)) {
throw new CLIError(
`Invalid config key "${key}". Valid keys: ${VALID_KEYS.join(', ')}`,
ExitCode.USAGE,
);
}

// Validate specific values
if (key === 'region' && !['global', 'cn'].includes(value)) {
if (resolvedKey === 'region' && !['global', 'cn'].includes(value)) {
throw new CLIError(
`Invalid region "${value}". Valid values: global, cn`,
ExitCode.USAGE,
);
}

if (key === 'output' && !['text', 'json'].includes(value)) {
if (resolvedKey === 'output' && !['text', 'json'].includes(value)) {
throw new CLIError(
`Invalid output format "${value}". Valid values: text, json`,
ExitCode.USAGE,
);
}

if (key === 'timeout') {
if (resolvedKey === 'timeout') {
const num = Number(value);
if (isNaN(num) || num <= 0) {
throw new CLIError(
Expand All @@ -68,16 +79,16 @@ export default defineCommand({
const format = detectOutputFormat(config.output);

if (config.dryRun) {
console.log(formatOutput({ would_set: { [key]: value } }, format));
console.log(formatOutput({ would_set: { [resolvedKey]: value } }, format));
return;
}

const existing = readConfigFile() as Record<string, unknown>;
existing[key] = key === 'timeout' ? Number(value) : value;
existing[resolvedKey] = resolvedKey === 'timeout' ? Number(value) : value;
await writeConfigFile(existing);

if (!config.quiet) {
console.log(formatOutput({ [key]: existing[key] }, format));
console.log(formatOutput({ [resolvedKey]: existing[resolvedKey] }, format));
}
},
});
6 changes: 6 additions & 0 deletions src/commands/config/show.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ export default defineCommand({
result.api_key = maskToken(file.api_key);
}

// Default models
if (file.default_text_model) result.default_text_model = file.default_text_model;
if (file.default_speech_model) result.default_speech_model = file.default_speech_model;
if (file.default_video_model) result.default_video_model = file.default_video_model;
if (file.default_music_model) result.default_music_model = file.default_music_model;

console.log(formatOutput(result, format));
},
});
8 changes: 8 additions & 0 deletions src/commands/music/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,17 @@ export function isCodingPlan(config: Config): boolean {
}

export function musicGenerateModel(config: Config): string {
// Config default > key-type-based default
if (config.defaultMusicModel) return config.defaultMusicModel;
return isCodingPlan(config) ? 'music-2.6' : 'music-2.6-free';
}

const VALID_COVER_MODELS = new Set(['music-cover', 'music-cover-free']);

export function musicCoverModel(config: Config): string {
// Config default (only if it's a valid cover model) > key-type-based default
if (config.defaultMusicModel && VALID_COVER_MODELS.has(config.defaultMusicModel)) {
return config.defaultMusicModel;
}
return isCodingPlan(config) ? 'music-cover' : 'music-cover-free';
}
4 changes: 3 additions & 1 deletion src/commands/speech/synthesize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ export default defineCommand({
);
}

const model = (flags.model as string) || 'speech-2.8-hd';
const model = (flags.model as string)
|| config.defaultSpeechModel
|| 'speech-2.8-hd';
const voice = (flags.voice as string) || 'English_expressive_narrator';
const ts = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-');
const ext = (flags.format as string) || 'mp3';
Expand Down
4 changes: 3 additions & 1 deletion src/commands/text/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,9 @@ export default defineCommand({
}
}

const model = (flags.model as string) || 'MiniMax-M2.7';
const model = (flags.model as string)
|| config.defaultTextModel
|| 'MiniMax-M2.7';
const shouldStream = flags.stream === true || (flags.stream === undefined && process.stdout.isTTY);
const format = detectOutputFormat(config.output);

Expand Down
12 changes: 8 additions & 4 deletions src/commands/video/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,17 @@ export default defineCommand({
);
}

// Determine model: explicit --model overrides auto-switch
// Determine model: explicit --model > auto-switch > config default > hardcoded
const explicitModel = flags.model as string | undefined;
let model = explicitModel || 'MiniMax-Hailuo-2.3';
if (!explicitModel && flags.lastFrame) {
let model: string;
if (explicitModel) {
model = explicitModel;
} else if (flags.lastFrame) {
model = 'MiniMax-Hailuo-02';
} else if (!explicitModel && flags.subjectImage) {
} else if (flags.subjectImage) {
model = 'S2V-01';
} else {
model = config.defaultVideoModel || 'MiniMax-Hailuo-2.3';
}
const format = detectOutputFormat(config.output);

Expand Down
4 changes: 4 additions & 0 deletions src/config/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ export function loadConfig(flags: GlobalFlags): Config {
baseUrl,
output,
timeout,
defaultTextModel: file.default_text_model,
defaultSpeechModel: file.default_speech_model,
defaultVideoModel: file.default_video_model,
defaultMusicModel: file.default_music_model,
verbose: flags.verbose || process.env.MINIMAX_VERBOSE === '1',
quiet: flags.quiet || false,
noColor: flags.noColor || process.env.NO_COLOR !== undefined || !process.stdout.isTTY,
Expand Down
12 changes: 12 additions & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ export interface ConfigFile {
base_url?: string;
output?: 'text' | 'json';
timeout?: number;
default_text_model?: string;
default_speech_model?: string;
default_video_model?: string;
default_music_model?: string;
}

const VALID_REGIONS = new Set<string>(['global', 'cn']);
Expand All @@ -31,6 +35,10 @@ export function parseConfigFile(raw: unknown): ConfigFile {
if (typeof obj.base_url === 'string' && obj.base_url.startsWith('http')) out.base_url = obj.base_url;
if (typeof obj.output === 'string' && VALID_OUTPUTS.has(obj.output)) out.output = obj.output as ConfigFile['output'];
if (typeof obj.timeout === 'number' && obj.timeout > 0) out.timeout = obj.timeout;
if (typeof obj.default_text_model === 'string' && obj.default_text_model.length > 0) out.default_text_model = obj.default_text_model;
if (typeof obj.default_speech_model === 'string' && obj.default_speech_model.length > 0) out.default_speech_model = obj.default_speech_model;
if (typeof obj.default_video_model === 'string' && obj.default_video_model.length > 0) out.default_video_model = obj.default_video_model;
if (typeof obj.default_music_model === 'string' && obj.default_music_model.length > 0) out.default_music_model = obj.default_music_model;

return out;
}
Expand All @@ -44,6 +52,10 @@ export interface Config {
baseUrl: string;
output: 'text' | 'json';
timeout: number;
defaultTextModel?: string;
defaultSpeechModel?: string;
defaultVideoModel?: string;
defaultMusicModel?: string;
verbose: boolean;
quiet: boolean;
noColor: boolean;
Expand Down
72 changes: 71 additions & 1 deletion test/commands/config/set.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { describe, it, expect } from 'bun:test';
import { describe, it, expect, mock } from 'bun:test';
import { default as setCommand } from '../../../src/commands/config/set';

// Mock file I/O
mock.module('../../../src/config/loader', () => ({
readConfigFile: () => ({}),
writeConfigFile: mock(() => Promise.resolve()),
}));

describe('config set command', () => {
it('has correct name', () => {
expect(setCommand.name).toBe('config set');
Expand Down Expand Up @@ -65,4 +71,68 @@ describe('config set command', () => {
}),
).rejects.toThrow('Invalid config key');
});

it('accepts default_text_model key', async () => {
const config = {
region: 'global' as const,
baseUrl: 'https://api.mmx.io',
output: 'text' as const,
timeout: 10,
verbose: false,
quiet: false,
noColor: true,
yes: false,
dryRun: true,
nonInteractive: true,
async: false,
};

// Should not throw — key is valid
await expect(
setCommand.execute(config, {
key: 'default_text_model',
value: 'MiniMax-M2.7-highspeed',
quiet: false,
verbose: false,
noColor: true,
yes: false,
dryRun: true,
help: false,
nonInteractive: true,
async: false,
}),
).resolves.toBeUndefined();
});

it('accepts hyphen alias default-text-model', async () => {
const config = {
region: 'global' as const,
baseUrl: 'https://api.mmx.io',
output: 'text' as const,
timeout: 10,
verbose: false,
quiet: false,
noColor: true,
yes: false,
dryRun: true,
nonInteractive: true,
async: false,
};

// Hyphen alias should resolve to default_text_model
await expect(
setCommand.execute(config, {
key: 'default-text-model',
value: 'MiniMax-M2.7-highspeed',
quiet: false,
verbose: false,
noColor: true,
yes: false,
dryRun: true,
help: false,
nonInteractive: true,
async: false,
}),
).resolves.toBeUndefined();
});
});
54 changes: 53 additions & 1 deletion test/commands/config/show.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
import { describe, it, expect } from 'bun:test';
import { describe, it, expect, mock } from 'bun:test';
import { default as showCommand } from '../../../src/commands/config/show';

// Mock file I/O
mock.module('../../../src/config/loader', () => ({
readConfigFile: () => ({
api_key: 'sk-cp-test-key',
default_text_model: 'MiniMax-M2.7-highspeed',
default_speech_model: 'speech-2.8-hd',
default_video_model: 'MiniMax-Hailuo-2.3-6s-768p',
default_music_model: 'music-2.6',
}),
}));

describe('config show command', () => {
it('has correct name', () => {
expect(showCommand.name).toBe('config show');
Expand Down Expand Up @@ -45,4 +56,45 @@ describe('config show command', () => {
console.log = originalLog;
}
});

it('includes default models in output', async () => {
const config = {
region: 'global' as const,
baseUrl: 'https://api.mmx.io',
output: 'json' as const,
timeout: 300,
verbose: false,
quiet: false,
noColor: true,
yes: false,
dryRun: false,
nonInteractive: true,
async: false,
};

const originalLog = console.log;
let output = '';
console.log = (msg: string) => { output += msg; };

try {
await showCommand.execute(config, {
quiet: false,
verbose: false,
noColor: true,
yes: false,
dryRun: false,
help: false,
nonInteractive: true,
async: false,
});

const parsed = JSON.parse(output);
expect(parsed.default_text_model).toBe('MiniMax-M2.7-highspeed');
expect(parsed.default_speech_model).toBe('speech-2.8-hd');
expect(parsed.default_video_model).toBe('MiniMax-Hailuo-2.3-6s-768p');
expect(parsed.default_music_model).toBe('music-2.6');
} finally {
console.log = originalLog;
}
});
});
38 changes: 38 additions & 0 deletions test/commands/music/generate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,4 +142,42 @@ describe('music generate command', () => {
}
expect(resolved).toBe(true);
});

it('uses defaultMusicModel when config is set', async () => {
let captured = '';
const origLog = console.log;
console.log = (msg: string) => { captured += msg; };

try {
await generateCommand.execute(
{ ...baseConfig, dryRun: true, output: 'json' as const, defaultMusicModel: 'music-2.6' },
{ ...baseFlags, dryRun: true, prompt: 'Folk', lyrics: 'no lyrics' },
);
} catch {
// dry-run may resolve or reject
}

console.log = origLog;
const parsed = JSON.parse(captured);
expect(parsed.request.model).toBe('music-2.6');
});

it('--model flag overrides defaultMusicModel', async () => {
let captured = '';
const origLog = console.log;
console.log = (msg: string) => { captured += msg; };

try {
await generateCommand.execute(
{ ...baseConfig, dryRun: true, output: 'json' as const, defaultMusicModel: 'music-2.6' },
{ ...baseFlags, dryRun: true, prompt: 'Folk', lyrics: 'no lyrics', model: 'music-2.5' },
);
} catch {
// dry-run may resolve or reject
}

console.log = origLog;
const parsed = JSON.parse(captured);
expect(parsed.request.model).toBe('music-2.5');
});
});
Loading
Loading