Skip to content

Commit 996b5d7

Browse files
committed
feat: install missing coding agents during setup
1 parent 77c802c commit 996b5d7

8 files changed

Lines changed: 739 additions & 30 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ mmx agent setup --agent codex --agent claude-code --api-key "$MINIMAX_API_KEY" -
190190
mmx agent setup --all --api-key "$MINIMAX_API_KEY" --region cn --output json
191191
```
192192

193-
The command verifies the key and selected region before writing. Existing files are backed up when changed; use `--dry-run` to preview without a live request. Agent setup only writes configuration files; it does not install or launch the selected agents.
193+
The command verifies the key and selected region before writing. Existing files are backed up when changed; use `--dry-run` to preview without a live request. In the interactive wizard, compatible agents that are missing from `PATH` can be installed from a second multi-select list using their official npm packages. Agents without a configuration-safe installer are explained and left configuration-only. Non-interactive invocations remain configuration-only, and the command never launches an agent.
194194

195195
### `mmx update`
196196

‎README_CN.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ mmx agent setup --agent codex --agent claude-code --api-key "$MINIMAX_API_KEY" -
166166
mmx agent setup --all --api-key "$MINIMAX_API_KEY" --region cn --output json
167167
```
168168

169-
写入前会验证 Key 和所选区域;修改已有文件时会创建备份。可用 `--dry-run` 预览且不会发起联网请求。该命令只管理配置文件,不会安装或启动所选 Agent。
169+
写入前会验证 Key 和所选区域;修改已有文件时会创建备份。可用 `--dry-run` 预览且不会发起联网请求。在交互式向导中,兼容且未在 `PATH` 中检测到的 Agent 会出现在第二个安装多选列表,并通过官方 npm 包安装;没有配置安全安装方式的 Agent 会说明原因并保持仅配置。非交互调用仍只写配置,且该命令不会启动 Agent。
170170

171171
### `mmx update`
172172

‎src/agent/configurator.ts‎

Lines changed: 32 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -878,6 +878,7 @@ export function prepareAgentConfigurations(options: AgentSetupOptions): Prepared
878878
break;
879879
}
880880
}
881+
assertDistinctConfigurationTargets(prepared);
881882
return prepared;
882883
}
883884

@@ -954,6 +955,20 @@ function hasWeakPermissions(mode: number): boolean {
954955
return process.platform !== 'win32' && (mode & 0o077) !== 0;
955956
}
956957

958+
function assertDistinctConfigurationTargets(prepared: PreparedAgentFile[]): void {
959+
const targets = new Set<string>();
960+
for (const file of prepared) {
961+
if (targets.has(file.targetPath)) {
962+
throw new CLIError(
963+
`Multiple agent configuration paths resolve to ${file.targetPath}.`,
964+
ExitCode.GENERAL,
965+
'No files were changed. Use distinct configuration paths and retry.',
966+
);
967+
}
968+
targets.add(file.targetPath);
969+
}
970+
}
971+
957972
function isProcessRunning(pid: number): boolean {
958973
try {
959974
process.kill(pid, 0);
@@ -979,7 +994,10 @@ function acquireAgentSetupLock(): () => void {
979994
if (readExisting(lockPath)?.trim() === token) rmSync(lockPath, { force: true });
980995
throw error;
981996
}
997+
let released = false;
982998
return () => {
999+
if (released) return;
1000+
released = true;
9831001
closeSync(descriptor);
9841002
if (readExisting(lockPath)?.trim() === token) rmSync(lockPath, { force: true });
9851003
};
@@ -1000,25 +1018,26 @@ function acquireAgentSetupLock(): () => void {
10001018
}
10011019
}
10021020

1021+
export async function withAgentSetupLock<T>(task: () => Promise<T>): Promise<T> {
1022+
const releaseLock = acquireAgentSetupLock();
1023+
process.once('exit', releaseLock);
1024+
try {
1025+
return await task();
1026+
} finally {
1027+
process.off('exit', releaseLock);
1028+
releaseLock();
1029+
}
1030+
}
1031+
10031032
export function applyAgentConfigurations(
10041033
prepared: PreparedAgentFile[],
10051034
dryRun = false,
1035+
lockHeld = false,
10061036
): AppliedAgentFile[] {
1007-
const releaseLock = dryRun ? undefined : acquireAgentSetupLock();
1037+
const releaseLock = dryRun || lockHeld ? undefined : acquireAgentSetupLock();
10081038
try {
10091039
const changed = prepared.filter((file) => file.before !== file.after);
1010-
const targets = new Set<string>();
1011-
for (const file of prepared) {
1012-
const target = file.targetPath;
1013-
if (targets.has(target)) {
1014-
throw new CLIError(
1015-
`Multiple agent configuration paths resolve to ${target}.`,
1016-
ExitCode.GENERAL,
1017-
'No files were changed. Use distinct configuration paths and retry.',
1018-
);
1019-
}
1020-
targets.add(target);
1021-
}
1040+
assertDistinctConfigurationTargets(prepared);
10221041
const originalModes = new Map<PreparedAgentFile, number>();
10231042
const permissionOnly = new Map<PreparedAgentFile, number>();
10241043
for (const file of prepared) {

‎src/agent/installer.ts‎

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import { spawn, spawnSync } from 'child_process';
2+
3+
import { CLIError } from '../errors/base';
4+
import { ExitCode } from '../errors/codes';
5+
import type { AgentId } from './types';
6+
7+
type NpmAgentId = Exclude<AgentId, 'grok' | 'hermes'>;
8+
9+
const NPM_PACKAGES: Record<NpmAgentId, string> = {
10+
'claude-code': '@anthropic-ai/claude-code@latest',
11+
codex: '@openai/codex',
12+
opencode: 'opencode-ai',
13+
pi: '@earendil-works/pi-coding-agent',
14+
};
15+
16+
const AGENT_EXECUTABLES: Record<NpmAgentId, string> = {
17+
'claude-code': 'claude',
18+
codex: 'codex',
19+
opencode: 'opencode',
20+
pi: 'pi',
21+
};
22+
23+
const SUPPORTED_PLATFORMS = new Set<NodeJS.Platform>(['darwin', 'linux', 'win32']);
24+
const SUPPORTED_NATIVE_ARCHITECTURES = new Set(['arm64', 'x64']);
25+
26+
export interface AgentInstallCommand {
27+
executable: string;
28+
args: string[];
29+
display: string;
30+
}
31+
32+
export interface AgentInstallEnvironment {
33+
platform?: NodeJS.Platform;
34+
arch?: string;
35+
nodeVersion?: string;
36+
commandExists?: (executable: string) => boolean;
37+
}
38+
39+
export type AgentInstallRunner = (command: AgentInstallCommand) => Promise<number>;
40+
41+
function defaultCommandExists(executable: string, platform: NodeJS.Platform): boolean {
42+
const command = platform === 'win32' ? 'where.exe' : executable;
43+
const args = platform === 'win32' ? [executable] : ['--version'];
44+
return spawnSync(command, args, { stdio: 'ignore', timeout: 5_000 }).status === 0;
45+
}
46+
47+
export function getAgentInstallIssue(
48+
agent: AgentId,
49+
environment: AgentInstallEnvironment = {},
50+
): string | undefined {
51+
if (agent === 'grok') {
52+
return 'The official Grok installer also changes Grok configuration; install it manually from '
53+
+ 'https://docs.x.ai/build/overview.';
54+
}
55+
if (agent === 'hermes') {
56+
return 'The official Hermes installer also changes shell configuration; install it manually from '
57+
+ 'https://github.com/NousResearch/hermes-agent.';
58+
}
59+
60+
const platform = environment.platform ?? process.platform;
61+
const arch = environment.arch ?? process.arch;
62+
const nodeVersion = environment.nodeVersion ?? process.versions.node;
63+
const commandExists = environment.commandExists
64+
?? ((executable: string) => defaultCommandExists(executable, platform));
65+
66+
if (!SUPPORTED_PLATFORMS.has(platform)) {
67+
return `Automated installation is not supported on ${platform}.`;
68+
}
69+
if (agent !== 'pi' && !SUPPORTED_NATIVE_ARCHITECTURES.has(arch)) {
70+
return `Automated installation is not supported on ${platform}/${arch}.`;
71+
}
72+
if (agent === 'pi') {
73+
const [major = 0, minor = 0] = nodeVersion.replace(/^v/, '').split('.').map(Number);
74+
if (major < 22 || (major === 22 && minor < 19)) {
75+
return `Pi requires Node.js 22.19 or newer (current: ${nodeVersion}).`;
76+
}
77+
}
78+
const requirements = platform === 'win32' ? ['cmd.exe', 'npm'] : ['npm'];
79+
const missing = requirements.filter(executable => !commandExists(executable));
80+
if (missing.length > 0) return `Required command not found on PATH: ${missing.join(', ')}.`;
81+
return undefined;
82+
}
83+
84+
function unavailableInstaller(agent: Extract<AgentId, 'grok' | 'hermes'>): never {
85+
throw new CLIError(
86+
`${agent} has no configuration-safe automated installer.`,
87+
ExitCode.GENERAL,
88+
getAgentInstallIssue(agent),
89+
);
90+
}
91+
92+
export function getAgentInstallCommand(
93+
agent: AgentId,
94+
platform: NodeJS.Platform = process.platform,
95+
): AgentInstallCommand {
96+
if (agent === 'grok' || agent === 'hermes') return unavailableInstaller(agent);
97+
98+
const args = [
99+
'install',
100+
'-g',
101+
...(agent === 'pi' ? ['--ignore-scripts', '--engine-strict'] : []),
102+
NPM_PACKAGES[agent],
103+
];
104+
const display = `npm ${args.join(' ')}`;
105+
if (platform === 'win32') {
106+
return {
107+
executable: 'cmd.exe',
108+
args: ['/d', '/s', '/c', display],
109+
display,
110+
};
111+
}
112+
return { executable: 'npm', args, display };
113+
}
114+
115+
export function getAgentVerificationCommand(
116+
agent: NpmAgentId,
117+
platform: NodeJS.Platform = process.platform,
118+
): AgentInstallCommand {
119+
const display = `${AGENT_EXECUTABLES[agent]} --version`;
120+
if (platform === 'win32') {
121+
return {
122+
executable: 'cmd.exe',
123+
args: ['/d', '/s', '/c', display],
124+
display,
125+
};
126+
}
127+
return { executable: AGENT_EXECUTABLES[agent], args: ['--version'], display };
128+
}
129+
130+
async function runAgentInstallCommand(command: AgentInstallCommand): Promise<number> {
131+
return new Promise((resolvePromise, reject) => {
132+
const child = spawn(command.executable, command.args, {
133+
stdio: ['inherit', process.stderr, 'inherit'],
134+
});
135+
child.once('error', reject);
136+
child.once('exit', code => resolvePromise(code ?? 1));
137+
});
138+
}
139+
140+
export async function installAgent(
141+
agent: AgentId,
142+
options: AgentInstallEnvironment & { runner?: AgentInstallRunner } = {},
143+
): Promise<void> {
144+
const issue = getAgentInstallIssue(agent, options);
145+
if (issue) {
146+
throw new CLIError(
147+
`Cannot install ${agent}: ${issue}`,
148+
ExitCode.GENERAL,
149+
'Leave it unselected and continue with configuration only.',
150+
);
151+
}
152+
const installableAgent = agent as NpmAgentId;
153+
const command = getAgentInstallCommand(installableAgent, options.platform);
154+
const verificationCommand = getAgentVerificationCommand(installableAgent, options.platform);
155+
const hint = `Run the installer manually:\n${command.display}\nFor npm permission errors, see: `
156+
+ 'https://docs.npmjs.com/resolving-eacces-permissions-errors-when-installing-packages-globally';
157+
const runner = options.runner ?? runAgentInstallCommand;
158+
159+
let exitCode: number;
160+
try {
161+
exitCode = await runner(command);
162+
} catch (error) {
163+
const detail = error instanceof Error ? ` ${error.message}` : '';
164+
throw new CLIError(
165+
`Could not start the installer for ${agent}.${detail}`,
166+
ExitCode.GENERAL,
167+
hint,
168+
);
169+
}
170+
if (exitCode !== 0) {
171+
throw new CLIError(
172+
`Failed to install ${agent} (installer exited with code ${exitCode}).`,
173+
ExitCode.GENERAL,
174+
hint,
175+
);
176+
}
177+
178+
let verificationExitCode: number;
179+
try {
180+
verificationExitCode = await runner(verificationCommand);
181+
} catch (error) {
182+
const detail = error instanceof Error ? ` ${error.message}` : '';
183+
throw new CLIError(
184+
`Installed ${agent}, but could not start ${verificationCommand.display}.${detail}`,
185+
ExitCode.GENERAL,
186+
`Open a new terminal and verify the installation manually:\n${verificationCommand.display}`,
187+
);
188+
}
189+
if (verificationExitCode !== 0) {
190+
throw new CLIError(
191+
`Installed ${agent}, but ${verificationCommand.display} exited with code ${verificationExitCode}.`,
192+
ExitCode.GENERAL,
193+
`Open a new terminal and verify the installation manually:\n${verificationCommand.display}`,
194+
);
195+
}
196+
}

0 commit comments

Comments
 (0)