From e40d3931cc8a4939e6c42c22f3d49336b115f0f9 Mon Sep 17 00:00:00 2001 From: SaladDay <92240037+SaladDay@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:39:09 +0800 Subject: [PATCH] feat(agent): add Zed agent setup --- README.md | 2 +- README_CN.md | 2 +- src/agent/availability.ts | 1 + src/agent/configurator.ts | 91 +++++++++++++++++++++++++++++++ src/agent/installer.ts | 9 ++- src/agent/types.ts | 1 + src/commands/agent/setup.ts | 18 ++++-- test/agent/zed.test.ts | 87 +++++++++++++++++++++++++++++ test/commands/agent/setup.test.ts | 2 +- 9 files changed, 204 insertions(+), 9 deletions(-) create mode 100644 test/agent/zed.test.ts diff --git a/README.md b/README.md index 0408d1e4..0f9ca5c6 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README_CN.md b/README_CN.md index c3231310..913abc8f 100644 --- a/README_CN.md +++ b/README_CN.md @@ -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 diff --git a/src/agent/availability.ts b/src/agent/availability.ts index b648bde5..22cb374c 100644 --- a/src/agent/availability.ts +++ b/src/agent/availability.ts @@ -11,6 +11,7 @@ const AGENT_EXECUTABLES: Record = { hermes: ['hermes'], pi: ['pi'], 'oh-my-pi': ['omp'], + zed: ['zed', 'zeditor'], }; const AGENT_RUNTIME_MARKERS: Partial> = { diff --git a/src/agent/configurator.ts b/src/agent/configurator.ts index 514bd5ee..8e7fcc41 100644 --- a/src/agent/configurator.ts +++ b/src/agent/configurator.ts @@ -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')]; + } } } @@ -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; 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).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) { @@ -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); diff --git a/src/agent/installer.ts b/src/agent/installer.ts index 52aefce1..e2831ac1 100644 --- a/src/agent/installer.ts +++ b/src/agent/installer.ts @@ -5,7 +5,7 @@ import { ExitCode } from '../errors/codes'; import type { AgentId } from './types'; type NpmAgentId = Extract; -type ScriptAgentId = Exclude; +type ScriptAgentId = Exclude; const NPM_PACKAGES: Record = { codex: '@openai/codex', @@ -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}.`; } @@ -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', @@ -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 { diff --git a/src/agent/types.ts b/src/agent/types.ts index 7ce0ff27..932a960d 100644 --- a/src/agent/types.ts +++ b/src/agent/types.ts @@ -9,6 +9,7 @@ export const AGENT_IDS = [ 'hermes', 'pi', 'oh-my-pi', + 'zed', ] as const; export type AgentId = typeof AGENT_IDS[number]; diff --git a/src/commands/agent/setup.ts b/src/commands/agent/setup.ts index 4db8c45f..21d5f271 100644 --- a/src/commands/agent/setup.ts +++ b/src/commands/agent/setup.ts @@ -55,6 +55,7 @@ const AGENT_ALIASES: Record = { pi: 'pi', 'oh-my-pi': 'oh-my-pi', omp: 'oh-my-pi', + zed: 'zed', }; const AGENT_LABELS: Record = { @@ -65,7 +66,11 @@ const AGENT_LABELS: Record = { 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 { @@ -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: [ @@ -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.' : ''), }); @@ -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, ); } @@ -491,7 +496,7 @@ export default defineCommand({ options: [ { flag: '--agent ', - 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', }, { @@ -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( diff --git a/test/agent/zed.test.ts b/test/agent/zed.test.ts new file mode 100644 index 00000000..a3f7f6ac --- /dev/null +++ b/test/agent/zed.test.ts @@ -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'); + }); +}); diff --git a/test/commands/agent/setup.test.ts b/test/commands/agent/setup.test.ts index dc4c3b24..8b4e853a 100644 --- a/test/commands/agent/setup.test.ts +++ b/test/commands/agent/setup.test.ts @@ -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 () => {