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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ mmx config export-schema | jq .

### `mmx agent setup`

Configure MiniMax for Claude Code, Codex, Grok Build, OpenCode, Hermes, Pi, or [Oh my Pi](https://github.com/can1357/oh-my-pi) while preserving unrelated settings. Run without options for the interactive wizard; supplying options makes the command non-interactive and suitable for scripts.
Configure MiniMax for Claude Code, Codex, Grok Build, OpenCode, Hermes, Pi, [Oh my Pi](https://github.com/can1357/oh-my-pi), or Zed while preserving unrelated settings. Run without options for the interactive wizard; supplying options makes the command non-interactive and suitable for scripts.

```bash
mmx agent setup
Expand Down
2 changes: 1 addition & 1 deletion README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ mmx config export-schema | jq .

### `mmx agent setup`

一键为 Claude Code、Codex、Grok Build、OpenCode、Hermes、Pi 或 [Oh my Pi](https://github.com/can1357/oh-my-pi) 配置 MiniMax,并保留配置文件中的其他设置。不带选项时进入交互式向导;带选项时自动使用非交互模式,便于脚本调用。
一键为 Claude Code、Codex、Grok Build、OpenCode、Hermes、Pi、[Oh my Pi](https://github.com/can1357/oh-my-pi) 或 Zed 配置 MiniMax,并保留配置文件中的其他设置。不带选项时进入交互式向导;带选项时自动使用非交互模式,便于脚本调用。

```bash
mmx agent setup
Expand Down
1 change: 1 addition & 0 deletions src/agent/availability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const AGENT_EXECUTABLES: Record<AgentId, readonly string[]> = {
hermes: ['hermes'],
pi: ['pi'],
'oh-my-pi': ['omp'],
zed: ['zed', 'zeditor'],
};

const AGENT_RUNTIME_MARKERS: Partial<Record<AgentId, readonly [string, string]>> = {
Expand Down
91 changes: 91 additions & 0 deletions src/agent/configurator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,17 @@ function configPathsForAgent(
}
case 'oh-my-pi':
return ohMyPiConfigPaths(home, env);
case 'zed': {
// Mirrors Zed's paths::config_dir().
if (process.platform === 'win32') {
return [join(env.APPDATA || join(home, 'AppData', 'Roaming'), 'Zed', 'settings.json')];
}
const xdgConfig = process.platform === 'darwin'
? join(home, '.config')
: env.FLATPAK_XDG_CONFIG_HOME
|| pathFromOverride(env.XDG_CONFIG_HOME, join(home, '.config'), home);
return [join(xdgConfig, 'zed', 'settings.json')];
}
}
}

Expand Down Expand Up @@ -1040,6 +1051,83 @@ function prepareOhMyPi(options: AgentSetupOptions, paths: string[]): PreparedAge
});
}

function prepareZed(options: AgentSetupOptions, path: string): PreparedAgentFile[] {
const prepared = readPrepared(path);
const label = 'Zed settings.json';
const parsed = parseJsoncObject(prepared.before ?? '{}', label);
const languageModels = parsed.language_models;
if (languageModels !== undefined) {
assertObject(languageModels, `${label} language_models must be an object.`);
}
const compatible = languageModels?.openai_compatible;
if (compatible !== undefined) {
assertObject(compatible, `${label} language_models.openai_compatible must be an object.`);
}
const provider = compatible?.minimax;
if (provider !== undefined) {
assertObject(provider, `${label} language_models.openai_compatible.minimax must be an object.`);
}
const existingModels = provider?.available_models;
if (existingModels !== undefined && !Array.isArray(existingModels)) {
throw new CLIError(
`${label} minimax available_models must be an array.`,
ExitCode.GENERAL,
INVALID_CONFIG_HINT,
);
}
const agentSettings = parsed.agent;
if (agentSettings !== undefined) assertObject(agentSettings, `${label} agent must be an object.`);
const defaultModel = agentSettings?.default_model;
if (defaultModel !== undefined) {
assertObject(defaultModel, `${label} agent.default_model must be an object.`);
}

// Thinking is always on for MiniMax models; Zed treats "none" as thinking disabled.
const effortFor = (modelId: MiniMaxModelId) => modelId === 'MiniMax-M3.1-Flash' ? 'max' : 'high';
const prefix = ['language_models', 'openai_compatible', 'minimax'];
const definitions = MINIMAX_MODELS.map(model => ({
name: model.id,
display_name: model.id,
max_tokens: model.id === 'MiniMax-M3.1-Flash'
? options.m31ContextWindow ?? 524288 : model.contextWindow,
max_output_tokens: model.maxTokens,
reasoning_effort: effortFor(model.id),
capabilities: {
tools: true,
images: model.input.some(input => input === 'image'),
parallel_tool_calls: true,
prompt_cache_key: false,
chat_completions: true,
interleaved_reasoning: true,
max_tokens_parameter: false,
},
}));
const updates: Array<{ path: Array<string | number>; value: unknown }> = [
{ path: [...prefix, 'api_url'], value: endpointsForRegion(options.region).openai },
];
if (existingModels === undefined) {
updates.push({ path: [...prefix, 'available_models'], value: definitions });
} else {
let appendIndex = existingModels.length;
for (const definition of definitions) {
const found = existingModels.findIndex(entry => typeof entry === 'object'
&& entry !== null
&& (entry as Record<string, unknown>).name === definition.name);
updates.push({
path: [...prefix, 'available_models', found >= 0 ? found : appendIndex++],
value: definition,
});
}
}
updates.push(
{ path: ['agent', 'default_model', 'provider'], value: 'minimax' },
{ path: ['agent', 'default_model', 'model'], value: options.model },
{ path: ['agent', 'default_model', 'enable_thinking'], value: true },
{ path: ['agent', 'default_model', 'effort'], value: effortFor(options.model) },
);
return [{ agent: 'zed', path, ...prepared, after: updateJsonc(prepared.before, label, updates) }];
}

export function prepareAgentConfigurations(options: AgentSetupOptions): PreparedAgentFile[] {
const prepared: PreparedAgentFile[] = [];
for (const agent of options.agents) {
Expand All @@ -1066,6 +1154,9 @@ export function prepareAgentConfigurations(options: AgentSetupOptions): Prepared
case 'oh-my-pi':
prepared.push(...prepareOhMyPi(options, paths));
break;
case 'zed':
prepared.push(...prepareZed(options, paths[0]!));
break;
}
}
assertDistinctConfigurationTargets(prepared);
Expand Down
9 changes: 8 additions & 1 deletion src/agent/installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { ExitCode } from '../errors/codes';
import type { AgentId } from './types';

type NpmAgentId = Extract<AgentId, 'codex' | 'opencode' | 'pi'>;
type ScriptAgentId = Exclude<AgentId, NpmAgentId>;
type ScriptAgentId = Exclude<AgentId, NpmAgentId | 'zed'>;

const NPM_PACKAGES: Record<NpmAgentId, string> = {
codex: '@openai/codex',
Expand Down Expand Up @@ -126,6 +126,7 @@ export function getAgentInstallIssue(
const commandExists = environment.commandExists
?? ((executable: string) => defaultCommandExists(executable, platform));

if (agent === 'zed') return 'Install Zed from https://zed.dev/download.';
if (!SUPPORTED_PLATFORMS.has(platform)) {
return `Automated installation is not supported on ${platform}.`;
}
Expand Down Expand Up @@ -224,6 +225,9 @@ export function getAgentInstallCommand(
if (agent === 'claude-code' || agent === 'grok' || agent === 'hermes' || agent === 'oh-my-pi') {
return getScriptInstallCommand(agent, platform);
}
if (agent === 'zed') {
throw new CLIError('Install Zed from https://zed.dev/download.', ExitCode.USAGE);
}

const args = [
'install',
Expand Down Expand Up @@ -294,6 +298,9 @@ export function getAgentVerificationCommand(
};
}

if (agent === 'zed') {
return { executable: 'zed', args: ['--version'], display: 'zed --version' };
}
const display = `${AGENT_EXECUTABLES[agent]} --version`;
if (platform === 'win32') {
return {
Expand Down
1 change: 1 addition & 0 deletions src/agent/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const AGENT_IDS = [
'hermes',
'pi',
'oh-my-pi',
'zed',
] as const;

export type AgentId = typeof AGENT_IDS[number];
Expand Down
18 changes: 13 additions & 5 deletions src/commands/agent/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const AGENT_ALIASES: Record<string, AgentId> = {
pi: 'pi',
'oh-my-pi': 'oh-my-pi',
omp: 'oh-my-pi',
zed: 'zed',
};

const AGENT_LABELS: Record<AgentId, string> = {
Expand All @@ -65,7 +66,11 @@ const AGENT_LABELS: Record<AgentId, string> = {
hermes: 'Hermes Agent',
pi: 'Pi',
'oh-my-pi': 'Oh my Pi (omp)',
zed: 'Zed',
};
// Zed keeps provider keys in the OS keychain or the MINIMAX_API_KEY environment variable.
const ZED_API_KEY_HINT = 'Zed: paste your MiniMax API key in Agent Panel > Settings > minimax, '
+ 'or start Zed with MINIMAX_API_KEY set.\n';
const DEFAULT_INTERACTIVE_AGENTS: AgentId[] = ['claude-code', 'codex'];

interface SelectedAgentSetup extends AgentSetupOptions {
Expand Down Expand Up @@ -319,7 +324,7 @@ async function interactiveOptions(
}

let m31ContextWindow: 524288 | 1_000_000 = 524288;
if (agents.some(agent => agent === 'claude-code' || agent === 'codex' || agent === 'opencode')) {
if (agents.some(agent => agent === 'claude-code' || agent === 'codex' || agent === 'opencode' || agent === 'zed')) {
const selectedWindow = await promptSelect({
message: 'Choose the MiniMax-M3.1-Flash context window',
choices: [
Expand Down Expand Up @@ -380,7 +385,7 @@ async function interactiveOptions(
title: 'Model for pay-as-you-go key',
message: 'MiniMax-M3.1-Flash currently requires Token Plan or MiniMax Code. '
+ 'This setup will use MiniMax-M3.'
+ (agents.some(agent => agent === 'claude-code' || agent === 'codex' || agent === 'opencode')
+ (agents.some(agent => agent === 'claude-code' || agent === 'codex' || agent === 'opencode' || agent === 'zed')
? ' The earlier window choice applies to the M3.1 model entry, not the active M3 model.'
: ''),
});
Expand Down Expand Up @@ -439,9 +444,9 @@ function nonInteractiveOptions(flags: GlobalFlags): SelectedAgentSetup {
);
}
if (requestedWindow !== undefined
&& !selected.some(agent => agent === 'claude-code' || agent === 'codex' || agent === 'opencode')) {
&& !selected.some(agent => agent === 'claude-code' || agent === 'codex' || agent === 'opencode' || agent === 'zed')) {
throw new CLIError(
'--m31-context-window requires Claude Code, Codex, or OpenCode.',
'--m31-context-window requires Claude Code, Codex, OpenCode, or Zed.',
ExitCode.USAGE,
);
}
Expand Down Expand Up @@ -491,7 +496,7 @@ export default defineCommand({
options: [
{
flag: '--agent <name>',
description: 'Agent: claude-code, codex, grok/grok-build, opencode, hermes, pi, oh-my-pi/omp (repeatable)',
description: 'Agent: claude-code, codex, grok/grok-build, opencode, hermes, pi, oh-my-pi/omp, zed (repeatable)',
type: 'array',
},
{
Expand Down Expand Up @@ -567,6 +572,9 @@ export default defineCommand({
agents: options.agents,
files,
}, format, interactive && !config.noColor));
if (options.agents.includes('zed') && !config.quiet) {
process.stderr.write(ZED_API_KEY_HINT);
}
const notDetected = options.agents.filter((agent) => !detectedAgents.has(agent));
if (!interactive && notDetected.length > 0 && !config.quiet) {
process.stderr.write(
Expand Down
87 changes: 87 additions & 0 deletions test/agent/zed.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { parse } from 'jsonc-parser/lib/esm/main.js';

import { applyAgentConfigurations, prepareAgentConfigurations } from '../../src/agent/configurator';
import type { AgentSetupOptions } from '../../src/agent/types';

describe('Zed configuration', () => {
let homeDir: string;
let options: AgentSetupOptions;

beforeEach(() => {
homeDir = mkdtempSync(join(tmpdir(), 'mmx-zed-'));
options = {
agents: ['zed'], homeDir, env: {}, region: 'global',
apiKey: 'sk-cp-test-only', model: 'MiniMax-M3.1-Flash',
};
});

afterEach(() => rmSync(homeDir, { recursive: true, force: true }));

it('merges JSONC settings for both regions, preserves user settings, and is idempotent', () => {
const dir = join(homeDir, '.config', 'zed');
const path = join(dir, 'settings.json');
mkdirSync(dir, { recursive: true });
writeFileSync(path, '// keep comment\n{\n "theme": "One Dark",\n "language_models": {\n'
+ ' "openai_compatible": {\n "other": { "api_url": "https://x", "available_models": [] },\n'
+ ' "minimax": { "available_models": [{ "name": "keep-model", "max_tokens": 1 },'
+ ' { "name": "MiniMax-M3", "max_tokens": 2 }] },\n },\n },\n'
+ ' "agent": { "default_model": { "provider": "zed.dev", "model": "x" }, "dock": "left" },\n}\n');
for (const region of ['global', 'cn'] as const) {
const prepared = prepareAgentConfigurations({ ...options, region });
expect(prepared.map(file => file.path)).toEqual([path]);
expect(applyAgentConfigurations(prepared)[0]?.backup).toBeDefined();
const text = readFileSync(path, 'utf8');
const settings = parse(text);
expect(text).toContain('// keep comment');
expect(settings.theme).toBe('One Dark');
expect(settings.agent.dock).toBe('left');
expect(settings.language_models.openai_compatible.other.api_url).toBe('https://x');
const provider = settings.language_models.openai_compatible.minimax;
expect(provider.api_url).toBe(`https://api.minimax.${region === 'cn' ? 'cn' : 'io'}/v1`);
expect(JSON.stringify(settings)).not.toContain(options.apiKey);
const models = provider.available_models;
expect(models.map((model: { name: string }) => model.name))
.toEqual(['keep-model', 'MiniMax-M3', 'MiniMax-M3.1-Flash']);
expect(models[2]).toMatchObject({
max_tokens: 524288, max_output_tokens: 128000, reasoning_effort: 'max',
capabilities: { tools: true, images: true, interleaved_reasoning: true, chat_completions: true },
});
expect(models[1]).toMatchObject({ max_tokens: 1_000_000, reasoning_effort: 'high' });
for (const model of models) expect(model.reasoning_effort).not.toBe('none');
expect(settings.agent.default_model).toEqual({
provider: 'minimax', model: 'MiniMax-M3.1-Flash', enable_thinking: true, effort: 'max',
});
}
const again = prepareAgentConfigurations({ ...options, region: 'cn' });
expect(again[0]?.before).toBe(again[0]?.after);
});

it('writes a fresh config with the selected model and honors XDG_CONFIG_HOME', () => {
const xdg = join(homeDir, 'xdg');
const [file] = prepareAgentConfigurations({
...options, model: 'MiniMax-M3', env: { XDG_CONFIG_HOME: xdg },
});
expect(file?.path).toBe(join(xdg, 'zed', 'settings.json'));
const settings = parse(file!.after);
expect(settings.agent.default_model).toMatchObject({ model: 'MiniMax-M3', effort: 'high' });
expect(settings.language_models.openai_compatible.minimax.available_models).toHaveLength(2);
});

it('uses the 1M Flash window when selected', () => {
const [file] = prepareAgentConfigurations({ ...options, m31ContextWindow: 1_000_000 });
const models = parse(file!.after).language_models.openai_compatible.minimax.available_models;
expect(models[0]).toMatchObject({ name: 'MiniMax-M3.1-Flash', max_tokens: 1_000_000 });
});

it('rejects a non-array available_models without changing files', () => {
const dir = join(homeDir, '.config', 'zed');
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'settings.json'),
'{ "language_models": { "openai_compatible": { "minimax": { "available_models": {} } } } }');
expect(() => prepareAgentConfigurations(options)).toThrow('available_models must be an array');
});
});
2 changes: 1 addition & 1 deletion test/commands/agent/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ describe('agent setup command', () => {
await expect(setupCommand.execute(
testConfig(),
testFlags({ agent: ['hermes'], apiKey: 'sk-test-secret', region: 'cn', m31ContextWindow: '1m' }),
)).rejects.toThrow('--m31-context-window requires Claude Code, Codex, or OpenCode');
)).rejects.toThrow('--m31-context-window requires Claude Code, Codex, OpenCode, or Zed');
});

it('requires an explicit region for scripts', async () => {
Expand Down
Loading