Skip to content

Commit 02fbdfb

Browse files
authored
Merge pull request #131 from MiniMax-AI/codex/file-cli-fixes
fix: register file commands, fix endpoints, harden SSE & region validation
2 parents 09f4728 + 8c602db commit 02fbdfb

12 files changed

Lines changed: 420 additions & 46 deletions

File tree

src/client/endpoints.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,9 @@ export function fileUploadEndpoint(baseUrl: string): string {
4949
}
5050

5151
export function fileListEndpoint(baseUrl: string): string {
52-
return `${baseUrl}/v1/files`;
52+
return `${baseUrl}/v1/files/list`;
5353
}
5454

55-
export function fileDeleteEndpoint(baseUrl: string, fileId: string): string {
56-
return `${baseUrl}/v1/files?file_id=${encodeURIComponent(fileId)}`;
55+
export function fileDeleteEndpoint(baseUrl: string): string {
56+
return `${baseUrl}/v1/files/delete`;
5757
}

src/client/stream.ts

Lines changed: 47 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,41 @@ export async function* parseSSE(response: Response): AsyncGenerator<ServerSentEv
1010

1111
const decoder = new TextDecoder();
1212
let buffer = '';
13+
let event: Partial<ServerSentEvent> = {};
14+
15+
const processLine = (rawLine: string): ServerSentEvent | undefined => {
16+
const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
17+
18+
if (line === '') {
19+
const completed = event.data !== undefined
20+
? { data: event.data, event: event.event, id: event.id }
21+
: undefined;
22+
event = {};
23+
return completed;
24+
}
25+
26+
if (line.startsWith(':')) return undefined;
27+
28+
const colonIndex = line.indexOf(':');
29+
if (colonIndex === -1) return undefined;
30+
31+
const field = line.slice(0, colonIndex);
32+
const value = line.slice(colonIndex + 1).trimStart();
33+
34+
switch (field) {
35+
case 'data':
36+
event.data = event.data !== undefined ? `${event.data}\n${value}` : value;
37+
break;
38+
case 'event':
39+
event.event = value;
40+
break;
41+
case 'id':
42+
event.id = value;
43+
break;
44+
}
45+
46+
return undefined;
47+
};
1348

1449
try {
1550
while (true) {
@@ -21,45 +56,25 @@ export async function* parseSSE(response: Response): AsyncGenerator<ServerSentEv
2156
const lines = buffer.split('\n');
2257
buffer = lines.pop() || '';
2358

24-
let event: Partial<ServerSentEvent> = {};
25-
2659
for (const line of lines) {
27-
if (line === '') {
28-
if (event.data !== undefined) {
29-
yield { data: event.data, event: event.event, id: event.id };
30-
}
31-
event = {};
32-
continue;
60+
const completed = processLine(line);
61+
if (completed) {
62+
yield completed;
3363
}
64+
}
65+
}
3466

35-
if (line.startsWith(':')) continue; // comment
36-
37-
const colonIndex = line.indexOf(':');
38-
if (colonIndex === -1) continue;
39-
40-
const field = line.slice(0, colonIndex);
41-
const value = line.slice(colonIndex + 1).trimStart();
67+
buffer += decoder.decode();
4268

43-
switch (field) {
44-
case 'data':
45-
event.data = event.data !== undefined ? `${event.data}\n${value}` : value;
46-
break;
47-
case 'event':
48-
event.event = value;
49-
break;
50-
case 'id':
51-
event.id = value;
52-
break;
53-
}
69+
if (buffer.length > 0) {
70+
const completed = processLine(buffer);
71+
if (completed) {
72+
yield completed;
5473
}
5574
}
5675

57-
// Flush remaining
58-
if (buffer.trim() && buffer.includes('data:')) {
59-
const colonIndex = buffer.indexOf(':');
60-
if (colonIndex !== -1) {
61-
yield { data: buffer.slice(colonIndex + 1).trimStart() };
62-
}
76+
if (event.data !== undefined) {
77+
yield { data: event.data, event: event.event, id: event.id };
6378
}
6479
} finally {
6580
reader.releaseLock();

src/commands/file/delete.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,20 +40,21 @@ export default defineCommand({
4040
return;
4141
}
4242

43-
const url = fileDeleteEndpoint(config.baseUrl, fileId);
43+
const url = fileDeleteEndpoint(config.baseUrl);
4444
const response = await requestJson<FileDeleteResponse>(config, {
4545
url,
46-
method: 'DELETE',
46+
method: 'POST',
47+
body: { file_id: Number(fileId) },
4748
});
4849

4950
if (config.quiet) {
50-
process.stdout.write(response.deleted ? 'deleted\n' : 'failed\n');
51+
process.stdout.write('deleted\n');
5152
return;
5253
}
5354

5455
process.stdout.write(formatOutput({
55-
id: response.id,
56-
deleted: response.deleted,
56+
file_id: response.file_id,
57+
deleted: true,
5758
}, format) + '\n');
5859
},
5960
});

src/commands/file/list.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,12 @@ export default defineCommand({
3030
return;
3131
}
3232

33-
if (!response.data || response.data.length === 0) {
33+
if (!response.files || response.files.length === 0) {
3434
process.stdout.write('No files found.\n');
3535
return;
3636
}
3737

38-
const tableData = response.data.map((f) => ({
38+
const tableData = response.files.map((f) => ({
3939
ID: f.file_id,
4040
FILENAME: f.filename,
4141
PURPOSE: f.purpose,

src/config/loader.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { readFileSync, writeFileSync, renameSync, existsSync } from 'fs';
22
import { parseConfigFile, REGIONS, type Config, type ConfigFile, type Region } from './schema';
33
import { ensureConfigDir, getConfigPath } from './paths';
44
import { detectOutputFormat, type OutputFormat } from '../output/formatter';
5+
import { CLIError } from '../errors/base';
6+
import { ExitCode } from '../errors/codes';
57
import type { GlobalFlags } from '../types/flags';
68

79
export function readConfigFile(): ConfigFile {
@@ -33,6 +35,13 @@ export function loadConfig(flags: GlobalFlags): Config {
3335
const fileApiKey = file.api_key;
3436

3537
const explicitRegion = (flags.region as string) || process.env.MINIMAX_REGION || undefined;
38+
if (explicitRegion && !(explicitRegion in REGIONS)) {
39+
throw new CLIError(
40+
`Invalid region "${explicitRegion}". Valid values: ${Object.keys(REGIONS).join(', ')}`,
41+
ExitCode.USAGE,
42+
);
43+
}
44+
3645
const cachedRegion = file.region;
3746
const region = (explicitRegion || cachedRegion || 'global') as Region;
3847

src/registry.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ import quotaShow from './commands/quota/show';
2222
import configShow from './commands/config/show';
2323
import configSet from './commands/config/set';
2424
import configExportSchema from './commands/config/export-schema';
25+
import fileUpload from './commands/file/upload';
26+
import fileList from './commands/file/list';
27+
import fileDelete from './commands/file/delete';
2528
import update from './commands/update';
2629
import help from './commands/help';
2730

@@ -197,6 +200,7 @@ ${b('Resources:')}
197200
${a('vision')} ${d('Image understanding (describe)')}
198201
${a('quota')} ${d('Usage quotas (show)')}
199202
${a('config')} ${d('CLI configuration (show, set, export-schema)')}
203+
${a('file')} ${d('File storage (upload, list, delete)')}
200204
${a('update')} ${d('Update mmx to a newer version')}
201205
202206
${b('Global Flags:')}
@@ -284,6 +288,9 @@ export const registry = new CommandRegistry({
284288
'config show': configShow,
285289
'config set': configSet,
286290
'config export-schema': configExportSchema,
291+
'file upload': fileUpload,
292+
'file list': fileList,
293+
'file delete': fileDelete,
287294
'update': update,
288295
'help': help,
289296
});

src/types/api.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,7 @@ export interface FileUploadResponse {
274274

275275
export interface FileListResponse {
276276
base_resp: BaseResp;
277-
data: Array<{
277+
files: Array<{
278278
file_id: string;
279279
bytes: number;
280280
created_at: number;
@@ -285,9 +285,7 @@ export interface FileListResponse {
285285

286286
export interface FileDeleteResponse {
287287
base_resp: BaseResp;
288-
id: string;
289-
object: string;
290-
deleted: boolean;
288+
file_id: number;
291289
}
292290

293291
export interface FileRetrieveResponse {

test/client/stream.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,45 @@ describe('parseSSE', () => {
241241
expect(events2[1].data).toBe('[DONE]');
242242
});
243243

244+
it('preserves event fields split across network chunks', async () => {
245+
const body = new ReadableStream<Uint8Array>({
246+
start(controller) {
247+
const encoder = new TextEncoder();
248+
controller.enqueue(encoder.encode('id: 7\nevent: mes'));
249+
controller.enqueue(encoder.encode('sage\ndata: hel'));
250+
controller.enqueue(encoder.encode('lo\n\n'));
251+
controller.close();
252+
},
253+
});
254+
255+
const events = await collectEvents(new Response(body));
256+
257+
expect(events).toEqual([{ id: '7', event: 'message', data: 'hello' }]);
258+
});
259+
260+
it('preserves multi-line data split across network chunks', async () => {
261+
const body = new ReadableStream<Uint8Array>({
262+
start(controller) {
263+
const encoder = new TextEncoder();
264+
controller.enqueue(encoder.encode('data: first\n'));
265+
controller.enqueue(encoder.encode('data: sec'));
266+
controller.enqueue(encoder.encode('ond\n\n'));
267+
controller.close();
268+
},
269+
});
270+
271+
const events = await collectEvents(new Response(body));
272+
273+
expect(events).toEqual([{ data: 'first\nsecond' }]);
274+
});
275+
276+
it('handles CRLF line endings', async () => {
277+
const body = 'event: message\r\ndata: hello\r\n\r\n';
278+
const events = await collectEvents(new Response(body));
279+
280+
expect(events).toEqual([{ event: 'message', data: 'hello' }]);
281+
});
282+
244283
// -------------------------------------------------------------------------
245284
// Helpers
246285
// -------------------------------------------------------------------------

test/commands/aliases.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ describe('command aliases', () => {
1313
const synthesize = registry.resolve(['speech', 'synthesize']);
1414
expect(generate.command).toBe(synthesize.command);
1515
});
16+
17+
it('resolves file storage commands', () => {
18+
expect(registry.resolve(['file', 'upload']).command.name).toBe('file upload');
19+
expect(registry.resolve(['file', 'list']).command.name).toBe('file list');
20+
expect(registry.resolve(['file', 'delete']).command.name).toBe('file delete');
21+
});
1622
});
1723

1824
describe('text chat --prompt alias', () => {

0 commit comments

Comments
 (0)