Skip to content

Commit d91a24c

Browse files
authored
feat(agent): add Zed agent setup (#297)
1 parent 6eeca87 commit d91a24c

9 files changed

Lines changed: 204 additions & 9 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ mmx config export-schema | jq .
204204

205205
### `mmx agent setup`
206206

207-
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.
207+
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.
208208

209209
```bash
210210
mmx agent setup

‎README_CN.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ mmx config export-schema | jq .
178178

179179
### `mmx agent setup`
180180

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

183183
```bash
184184
mmx agent setup

‎src/agent/availability.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const AGENT_EXECUTABLES: Record<AgentId, readonly string[]> = {
1111
hermes: ['hermes'],
1212
pi: ['pi'],
1313
'oh-my-pi': ['omp'],
14+
zed: ['zed', 'zeditor'],
1415
};
1516

1617
const AGENT_RUNTIME_MARKERS: Partial<Record<AgentId, readonly [string, string]>> = {

‎src/agent/configurator.ts‎

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,17 @@ function configPathsForAgent(
156156
}
157157
case 'oh-my-pi':
158158
return ohMyPiConfigPaths(home, env);
159+
case 'zed': {
160+
// Mirrors Zed's paths::config_dir().
161+
if (process.platform === 'win32') {
162+
return [join(env.APPDATA || join(home, 'AppData', 'Roaming'), 'Zed', 'settings.json')];
163+
}
164+
const xdgConfig = process.platform === 'darwin'
165+
? join(home, '.config')
166+
: env.FLATPAK_XDG_CONFIG_HOME
167+
|| pathFromOverride(env.XDG_CONFIG_HOME, join(home, '.config'), home);
168+
return [join(xdgConfig, 'zed', 'settings.json')];
169+
}
159170
}
160171
}
161172

@@ -1040,6 +1051,83 @@ function prepareOhMyPi(options: AgentSetupOptions, paths: string[]): PreparedAge
10401051
});
10411052
}
10421053

1054+
function prepareZed(options: AgentSetupOptions, path: string): PreparedAgentFile[] {
1055+
const prepared = readPrepared(path);
1056+
const label = 'Zed settings.json';
1057+
const parsed = parseJsoncObject(prepared.before ?? '{}', label);
1058+
const languageModels = parsed.language_models;
1059+
if (languageModels !== undefined) {
1060+
assertObject(languageModels, `${label} language_models must be an object.`);
1061+
}
1062+
const compatible = languageModels?.openai_compatible;
1063+
if (compatible !== undefined) {
1064+
assertObject(compatible, `${label} language_models.openai_compatible must be an object.`);
1065+
}
1066+
const provider = compatible?.minimax;
1067+
if (provider !== undefined) {
1068+
assertObject(provider, `${label} language_models.openai_compatible.minimax must be an object.`);
1069+
}
1070+
const existingModels = provider?.available_models;
1071+
if (existingModels !== undefined && !Array.isArray(existingModels)) {
1072+
throw new CLIError(
1073+
`${label} minimax available_models must be an array.`,
1074+
ExitCode.GENERAL,
1075+
INVALID_CONFIG_HINT,
1076+
);
1077+
}
1078+
const agentSettings = parsed.agent;
1079+
if (agentSettings !== undefined) assertObject(agentSettings, `${label} agent must be an object.`);
1080+
const defaultModel = agentSettings?.default_model;
1081+
if (defaultModel !== undefined) {
1082+
assertObject(defaultModel, `${label} agent.default_model must be an object.`);
1083+
}
1084+
1085+
// Thinking is always on for MiniMax models; Zed treats "none" as thinking disabled.
1086+
const effortFor = (modelId: MiniMaxModelId) => modelId === 'MiniMax-M3.1-Flash' ? 'max' : 'high';
1087+
const prefix = ['language_models', 'openai_compatible', 'minimax'];
1088+
const definitions = MINIMAX_MODELS.map(model => ({
1089+
name: model.id,
1090+
display_name: model.id,
1091+
max_tokens: model.id === 'MiniMax-M3.1-Flash'
1092+
? options.m31ContextWindow ?? 524288 : model.contextWindow,
1093+
max_output_tokens: model.maxTokens,
1094+
reasoning_effort: effortFor(model.id),
1095+
capabilities: {
1096+
tools: true,
1097+
images: model.input.some(input => input === 'image'),
1098+
parallel_tool_calls: true,
1099+
prompt_cache_key: false,
1100+
chat_completions: true,
1101+
interleaved_reasoning: true,
1102+
max_tokens_parameter: false,
1103+
},
1104+
}));
1105+
const updates: Array<{ path: Array<string | number>; value: unknown }> = [
1106+
{ path: [...prefix, 'api_url'], value: endpointsForRegion(options.region).openai },
1107+
];
1108+
if (existingModels === undefined) {
1109+
updates.push({ path: [...prefix, 'available_models'], value: definitions });
1110+
} else {
1111+
let appendIndex = existingModels.length;
1112+
for (const definition of definitions) {
1113+
const found = existingModels.findIndex(entry => typeof entry === 'object'
1114+
&& entry !== null
1115+
&& (entry as Record<string, unknown>).name === definition.name);
1116+
updates.push({
1117+
path: [...prefix, 'available_models', found >= 0 ? found : appendIndex++],
1118+
value: definition,
1119+
});
1120+
}
1121+
}
1122+
updates.push(
1123+
{ path: ['agent', 'default_model', 'provider'], value: 'minimax' },
1124+
{ path: ['agent', 'default_model', 'model'], value: options.model },
1125+
{ path: ['agent', 'default_model', 'enable_thinking'], value: true },
1126+
{ path: ['agent', 'default_model', 'effort'], value: effortFor(options.model) },
1127+
);
1128+
return [{ agent: 'zed', path, ...prepared, after: updateJsonc(prepared.before, label, updates) }];
1129+
}
1130+
10431131
export function prepareAgentConfigurations(options: AgentSetupOptions): PreparedAgentFile[] {
10441132
const prepared: PreparedAgentFile[] = [];
10451133
for (const agent of options.agents) {
@@ -1066,6 +1154,9 @@ export function prepareAgentConfigurations(options: AgentSetupOptions): Prepared
10661154
case 'oh-my-pi':
10671155
prepared.push(...prepareOhMyPi(options, paths));
10681156
break;
1157+
case 'zed':
1158+
prepared.push(...prepareZed(options, paths[0]!));
1159+
break;
10691160
}
10701161
}
10711162
assertDistinctConfigurationTargets(prepared);

‎src/agent/installer.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { ExitCode } from '../errors/codes';
55
import type { AgentId } from './types';
66

77
type NpmAgentId = Extract<AgentId, 'codex' | 'opencode' | 'pi'>;
8-
type ScriptAgentId = Exclude<AgentId, NpmAgentId>;
8+
type ScriptAgentId = Exclude<AgentId, NpmAgentId | 'zed'>;
99

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

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

228232
const args = [
229233
'install',
@@ -294,6 +298,9 @@ export function getAgentVerificationCommand(
294298
};
295299
}
296300

301+
if (agent === 'zed') {
302+
return { executable: 'zed', args: ['--version'], display: 'zed --version' };
303+
}
297304
const display = `${AGENT_EXECUTABLES[agent]} --version`;
298305
if (platform === 'win32') {
299306
return {

‎src/agent/types.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export const AGENT_IDS = [
99
'hermes',
1010
'pi',
1111
'oh-my-pi',
12+
'zed',
1213
] as const;
1314

1415
export type AgentId = typeof AGENT_IDS[number];

‎src/commands/agent/setup.ts‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ const AGENT_ALIASES: Record<string, AgentId> = {
5555
pi: 'pi',
5656
'oh-my-pi': 'oh-my-pi',
5757
omp: 'oh-my-pi',
58+
zed: 'zed',
5859
};
5960

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

7176
interface SelectedAgentSetup extends AgentSetupOptions {
@@ -319,7 +324,7 @@ async function interactiveOptions(
319324
}
320325

321326
let m31ContextWindow: 524288 | 1_000_000 = 524288;
322-
if (agents.some(agent => agent === 'claude-code' || agent === 'codex' || agent === 'opencode')) {
327+
if (agents.some(agent => agent === 'claude-code' || agent === 'codex' || agent === 'opencode' || agent === 'zed')) {
323328
const selectedWindow = await promptSelect({
324329
message: 'Choose the MiniMax-M3.1-Flash context window',
325330
choices: [
@@ -380,7 +385,7 @@ async function interactiveOptions(
380385
title: 'Model for pay-as-you-go key',
381386
message: 'MiniMax-M3.1-Flash currently requires Token Plan or MiniMax Code. '
382387
+ 'This setup will use MiniMax-M3.'
383-
+ (agents.some(agent => agent === 'claude-code' || agent === 'codex' || agent === 'opencode')
388+
+ (agents.some(agent => agent === 'claude-code' || agent === 'codex' || agent === 'opencode' || agent === 'zed')
384389
? ' The earlier window choice applies to the M3.1 model entry, not the active M3 model.'
385390
: ''),
386391
});
@@ -439,9 +444,9 @@ function nonInteractiveOptions(flags: GlobalFlags): SelectedAgentSetup {
439444
);
440445
}
441446
if (requestedWindow !== undefined
442-
&& !selected.some(agent => agent === 'claude-code' || agent === 'codex' || agent === 'opencode')) {
447+
&& !selected.some(agent => agent === 'claude-code' || agent === 'codex' || agent === 'opencode' || agent === 'zed')) {
443448
throw new CLIError(
444-
'--m31-context-window requires Claude Code, Codex, or OpenCode.',
449+
'--m31-context-window requires Claude Code, Codex, OpenCode, or Zed.',
445450
ExitCode.USAGE,
446451
);
447452
}
@@ -491,7 +496,7 @@ export default defineCommand({
491496
options: [
492497
{
493498
flag: '--agent <name>',
494-
description: 'Agent: claude-code, codex, grok/grok-build, opencode, hermes, pi, oh-my-pi/omp (repeatable)',
499+
description: 'Agent: claude-code, codex, grok/grok-build, opencode, hermes, pi, oh-my-pi/omp, zed (repeatable)',
495500
type: 'array',
496501
},
497502
{
@@ -567,6 +572,9 @@ export default defineCommand({
567572
agents: options.agents,
568573
files,
569574
}, format, interactive && !config.noColor));
575+
if (options.agents.includes('zed') && !config.quiet) {
576+
process.stderr.write(ZED_API_KEY_HINT);
577+
}
570578
const notDetected = options.agents.filter((agent) => !detectedAgents.has(agent));
571579
if (!interactive && notDetected.length > 0 && !config.quiet) {
572580
process.stderr.write(

‎test/agent/zed.test.ts‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
3+
import { tmpdir } from 'os';
4+
import { join } from 'path';
5+
import { parse } from 'jsonc-parser/lib/esm/main.js';
6+
7+
import { applyAgentConfigurations, prepareAgentConfigurations } from '../../src/agent/configurator';
8+
import type { AgentSetupOptions } from '../../src/agent/types';
9+
10+
describe('Zed configuration', () => {
11+
let homeDir: string;
12+
let options: AgentSetupOptions;
13+
14+
beforeEach(() => {
15+
homeDir = mkdtempSync(join(tmpdir(), 'mmx-zed-'));
16+
options = {
17+
agents: ['zed'], homeDir, env: {}, region: 'global',
18+
apiKey: 'sk-cp-test-only', model: 'MiniMax-M3.1-Flash',
19+
};
20+
});
21+
22+
afterEach(() => rmSync(homeDir, { recursive: true, force: true }));
23+
24+
it('merges JSONC settings for both regions, preserves user settings, and is idempotent', () => {
25+
const dir = join(homeDir, '.config', 'zed');
26+
const path = join(dir, 'settings.json');
27+
mkdirSync(dir, { recursive: true });
28+
writeFileSync(path, '// keep comment\n{\n "theme": "One Dark",\n "language_models": {\n'
29+
+ ' "openai_compatible": {\n "other": { "api_url": "https://x", "available_models": [] },\n'
30+
+ ' "minimax": { "available_models": [{ "name": "keep-model", "max_tokens": 1 },'
31+
+ ' { "name": "MiniMax-M3", "max_tokens": 2 }] },\n },\n },\n'
32+
+ ' "agent": { "default_model": { "provider": "zed.dev", "model": "x" }, "dock": "left" },\n}\n');
33+
for (const region of ['global', 'cn'] as const) {
34+
const prepared = prepareAgentConfigurations({ ...options, region });
35+
expect(prepared.map(file => file.path)).toEqual([path]);
36+
expect(applyAgentConfigurations(prepared)[0]?.backup).toBeDefined();
37+
const text = readFileSync(path, 'utf8');
38+
const settings = parse(text);
39+
expect(text).toContain('// keep comment');
40+
expect(settings.theme).toBe('One Dark');
41+
expect(settings.agent.dock).toBe('left');
42+
expect(settings.language_models.openai_compatible.other.api_url).toBe('https://x');
43+
const provider = settings.language_models.openai_compatible.minimax;
44+
expect(provider.api_url).toBe(`https://api.minimax.${region === 'cn' ? 'cn' : 'io'}/v1`);
45+
expect(JSON.stringify(settings)).not.toContain(options.apiKey);
46+
const models = provider.available_models;
47+
expect(models.map((model: { name: string }) => model.name))
48+
.toEqual(['keep-model', 'MiniMax-M3', 'MiniMax-M3.1-Flash']);
49+
expect(models[2]).toMatchObject({
50+
max_tokens: 524288, max_output_tokens: 128000, reasoning_effort: 'max',
51+
capabilities: { tools: true, images: true, interleaved_reasoning: true, chat_completions: true },
52+
});
53+
expect(models[1]).toMatchObject({ max_tokens: 1_000_000, reasoning_effort: 'high' });
54+
for (const model of models) expect(model.reasoning_effort).not.toBe('none');
55+
expect(settings.agent.default_model).toEqual({
56+
provider: 'minimax', model: 'MiniMax-M3.1-Flash', enable_thinking: true, effort: 'max',
57+
});
58+
}
59+
const again = prepareAgentConfigurations({ ...options, region: 'cn' });
60+
expect(again[0]?.before).toBe(again[0]?.after);
61+
});
62+
63+
it('writes a fresh config with the selected model and honors XDG_CONFIG_HOME', () => {
64+
const xdg = join(homeDir, 'xdg');
65+
const [file] = prepareAgentConfigurations({
66+
...options, model: 'MiniMax-M3', env: { XDG_CONFIG_HOME: xdg },
67+
});
68+
expect(file?.path).toBe(join(xdg, 'zed', 'settings.json'));
69+
const settings = parse(file!.after);
70+
expect(settings.agent.default_model).toMatchObject({ model: 'MiniMax-M3', effort: 'high' });
71+
expect(settings.language_models.openai_compatible.minimax.available_models).toHaveLength(2);
72+
});
73+
74+
it('uses the 1M Flash window when selected', () => {
75+
const [file] = prepareAgentConfigurations({ ...options, m31ContextWindow: 1_000_000 });
76+
const models = parse(file!.after).language_models.openai_compatible.minimax.available_models;
77+
expect(models[0]).toMatchObject({ name: 'MiniMax-M3.1-Flash', max_tokens: 1_000_000 });
78+
});
79+
80+
it('rejects a non-array available_models without changing files', () => {
81+
const dir = join(homeDir, '.config', 'zed');
82+
mkdirSync(dir, { recursive: true });
83+
writeFileSync(join(dir, 'settings.json'),
84+
'{ "language_models": { "openai_compatible": { "minimax": { "available_models": {} } } } }');
85+
expect(() => prepareAgentConfigurations(options)).toThrow('available_models must be an array');
86+
});
87+
});

‎test/commands/agent/setup.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ describe('agent setup command', () => {
142142
await expect(setupCommand.execute(
143143
testConfig(),
144144
testFlags({ agent: ['hermes'], apiKey: 'sk-test-secret', region: 'cn', m31ContextWindow: '1m' }),
145-
)).rejects.toThrow('--m31-context-window requires Claude Code, Codex, or OpenCode');
145+
)).rejects.toThrow('--m31-context-window requires Claude Code, Codex, OpenCode, or Zed');
146146
});
147147

148148
it('requires an explicit region for scripts', async () => {

0 commit comments

Comments
 (0)