diff --git a/README.md b/README.md index 3033b3a..fd88f9e 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,7 @@ mmx agent setup --agent codex --agent claude-code --api-key "$MINIMAX_API_KEY" - mmx agent setup --all --api-key "$MINIMAX_API_KEY" --region cn --output json ``` -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. +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 packages or installer scripts. The Hermes install uses its official core CLI stages and skips optional system packages, Node workspace, browser, computer-use, setup, and gateway stages. After installation, mmx applies the MiniMax configuration while preserving installer settings. Agents that fail a platform or prerequisite check are explained and left configuration-only. Non-interactive invocations remain configuration-only, and the command never launches an agent. ### `mmx update` diff --git a/README_CN.md b/README_CN.md index 5e485bd..5b7aa52 100644 --- a/README_CN.md +++ b/README_CN.md @@ -166,7 +166,7 @@ mmx agent setup --agent codex --agent claude-code --api-key "$MINIMAX_API_KEY" - mmx agent setup --all --api-key "$MINIMAX_API_KEY" --region cn --output json ``` -写入前会验证 Key 和所选区域;修改已有文件时会创建备份。可用 `--dry-run` 预览且不会发起联网请求。该命令只管理配置文件,不会安装或启动所选 Agent。 +写入前会验证 Key 和所选区域;修改已有文件时会创建备份。可用 `--dry-run` 预览且不会发起联网请求。在交互式向导中,兼容且未在 `PATH` 中检测到的 Agent 会出现在第二个安装多选列表,并通过官方软件包或安装脚本安装。Hermes 仅执行官方核心 CLI 安装阶段,跳过可选系统软件包、Node 工作区、浏览器、计算机控制、初始化和网关阶段。安装完成后,mmx 会保留安装器生成的其他设置并写入 MiniMax 配置。平台或依赖检查不通过的 Agent 会说明原因并保持仅配置。非交互调用仍只写配置,且该命令不会启动 Agent。 ### `mmx update` diff --git a/src/agent/configurator.ts b/src/agent/configurator.ts index c6a1e74..f567561 100644 --- a/src/agent/configurator.ts +++ b/src/agent/configurator.ts @@ -297,6 +297,7 @@ function updateToml( sections: Array<{ name: string; entries: Record }>, ): string { let source = before ?? ''; + if (source.charCodeAt(0) === 0xfeff) source = source.slice(1); try { parseToml(source); } catch { @@ -363,6 +364,20 @@ function updateHermesYaml( 'Hermes config.yaml providers section must be an object.', ); } + if (root.agent !== undefined) { + assertObject( + root.agent, + 'Hermes config.yaml agent section must be an object.', + ); + } + const reasoningOverrides = (root.agent as Record | undefined) + ?.reasoning_overrides; + if (reasoningOverrides !== undefined) { + assertObject( + reasoningOverrides, + 'Hermes config.yaml agent.reasoning_overrides section must be an object.', + ); + } const providerConfig = (root.providers as Record | undefined)?.[provider]; if (providerConfig !== undefined) { assertObject( @@ -418,6 +433,11 @@ function updateHermesYaml( document.setIn(['model', 'base_url'], baseUrl); document.setIn(['model', 'context_length'], selectedModel.contextWindow); document.setIn(['model', 'max_tokens'], selectedModel.maxTokens); + if ((reasoningOverrides as Record | undefined)?.['MiniMax-M3'] === undefined) { + // Hermes currently serializes enabled MiniMax thinking with the legacy + // budget-based shape. Omitting thinking is the safe M3 default. + document.setIn(['agent', 'reasoning_overrides', 'MiniMax-M3'], 'none'); + } return document.toString(); } @@ -534,10 +554,12 @@ function codexModelCatalog(): string { display_name: model.id, description: 'MiniMax', default_reasoning_level: 'high', - supported_reasoning_levels: [ - { effort: 'none', description: 'Think-Off' }, - { effort: 'high', description: 'Deep' }, - ], + supported_reasoning_levels: model.id === 'MiniMax-M3' + ? [ + { effort: 'none', description: 'Think-Off' }, + { effort: 'high', description: 'Deep' }, + ] + : [{ effort: 'high', description: 'Always on' }], shell_type: 'shell_command', visibility: 'list', supported_in_api: true, @@ -797,6 +819,9 @@ function preparePi(options: AgentSetupOptions, paths: string[]): PreparedAgentFi id: model.id, name: model.id, reasoning: true, + ...(model.id === 'MiniMax-M3' + ? { compat: { forceAdaptiveThinking: true } } + : { thinkingLevelMap: { off: null } }), input: [...model.input], contextWindow: model.contextWindow, maxTokens: model.maxTokens, @@ -830,7 +855,21 @@ function preparePi(options: AgentSetupOptions, paths: string[]): PreparedAgentFi `Pi model definition for ${definition.id} must be an object.`, ); for (const [key, value] of Object.entries(definition)) { - if (key !== 'id') providerUpdates.push({ path: [...modelPath, key], value }); + if (key === 'id') continue; + if (key === 'compat' || key === 'thinkingLevelMap') { + const existing = existingModels[modelIndex][key]; + if (existing !== undefined) { + assertObject(existing, `Pi ${key} for ${definition.id} must be an object.`); + } + for (const [nestedKey, nestedValue] of Object.entries(value)) { + providerUpdates.push({ + path: [...modelPath, key, nestedKey], + value: nestedValue, + }); + } + continue; + } + providerUpdates.push({ path: [...modelPath, key], value }); } } } @@ -878,6 +917,7 @@ export function prepareAgentConfigurations(options: AgentSetupOptions): Prepared break; } } + assertDistinctConfigurationTargets(prepared); return prepared; } @@ -954,6 +994,20 @@ function hasWeakPermissions(mode: number): boolean { return process.platform !== 'win32' && (mode & 0o077) !== 0; } +function assertDistinctConfigurationTargets(prepared: PreparedAgentFile[]): void { + const targets = new Set(); + for (const file of prepared) { + if (targets.has(file.targetPath)) { + throw new CLIError( + `Multiple agent configuration paths resolve to ${file.targetPath}.`, + ExitCode.GENERAL, + 'No files were changed. Use distinct configuration paths and retry.', + ); + } + targets.add(file.targetPath); + } +} + function isProcessRunning(pid: number): boolean { try { process.kill(pid, 0); @@ -979,7 +1033,10 @@ function acquireAgentSetupLock(): () => void { if (readExisting(lockPath)?.trim() === token) rmSync(lockPath, { force: true }); throw error; } + let released = false; return () => { + if (released) return; + released = true; closeSync(descriptor); if (readExisting(lockPath)?.trim() === token) rmSync(lockPath, { force: true }); }; @@ -1000,25 +1057,46 @@ function acquireAgentSetupLock(): () => void { } } +export async function withAgentSetupLock(task: () => Promise): Promise { + const releaseLock = acquireAgentSetupLock(); + const signalHandlers = new Map void>(); + const removeSignalHandlers = () => { + for (const [signal, handler] of signalHandlers) { + process.removeListener(signal, handler); + } + }; + for (const [signal, exitCode] of [ + ['SIGHUP', 129], + ['SIGINT', 130], + ['SIGTERM', 143], + ] as const) { + const handler = () => { + removeSignalHandlers(); + releaseLock(); + process.exit(exitCode); + }; + signalHandlers.set(signal, handler); + process.once(signal, handler); + } + process.once('exit', releaseLock); + try { + return await task(); + } finally { + process.off('exit', releaseLock); + removeSignalHandlers(); + releaseLock(); + } +} + export function applyAgentConfigurations( prepared: PreparedAgentFile[], dryRun = false, + lockHeld = false, ): AppliedAgentFile[] { - const releaseLock = dryRun ? undefined : acquireAgentSetupLock(); + const releaseLock = dryRun || lockHeld ? undefined : acquireAgentSetupLock(); try { const changed = prepared.filter((file) => file.before !== file.after); - const targets = new Set(); - for (const file of prepared) { - const target = file.targetPath; - if (targets.has(target)) { - throw new CLIError( - `Multiple agent configuration paths resolve to ${target}.`, - ExitCode.GENERAL, - 'No files were changed. Use distinct configuration paths and retry.', - ); - } - targets.add(target); - } + assertDistinctConfigurationTargets(prepared); const originalModes = new Map(); const permissionOnly = new Map(); for (const file of prepared) { diff --git a/src/agent/installer.ts b/src/agent/installer.ts new file mode 100644 index 0000000..9d78042 --- /dev/null +++ b/src/agent/installer.ts @@ -0,0 +1,462 @@ +import { spawn, spawnSync, type ChildProcess } from 'child_process'; + +import { CLIError } from '../errors/base'; +import { ExitCode } from '../errors/codes'; +import type { AgentId } from './types'; + +type NpmAgentId = Extract; +type ScriptAgentId = Exclude; + +const NPM_PACKAGES: Record = { + codex: '@openai/codex', + opencode: 'opencode-ai', + pi: '@earendil-works/pi-coding-agent', +}; + +const AGENT_EXECUTABLES: Record = { + codex: 'codex', + opencode: 'opencode', + pi: 'pi', +}; + +const INSTALLER_URLS: Record = { + 'claude-code': 'https://claude.ai/install.sh', + grok: 'https://x.ai/cli/install.sh', + hermes: 'https://hermes-agent.nousresearch.com/install.sh', +}; + +const WINDOWS_INSTALLER_URLS: Record = { + 'claude-code': 'https://claude.ai/install.ps1', + grok: 'https://x.ai/cli/install.ps1', + hermes: 'https://hermes-agent.nousresearch.com/install.ps1', +}; + +// These mirror the official manifest's non-interactive stages. Setup, gateway, +// and the opt-in desktop build are intentionally excluded. +const HERMES_POSIX_STAGES = [ + 'prerequisites', + 'repository', + 'venv', + 'python-deps', + 'node-deps', + 'path', + 'config', + 'complete', +]; + +const HERMES_WINDOWS_STAGES = [ + 'uv', + 'python', + 'git', + 'node', + 'system-packages', + 'repository', + 'venv', + 'dependencies', + 'node-deps', + 'path', + 'config-templates', + 'platform-sdks', + 'bootstrap-marker', +]; + +const SUPPORTED_PLATFORMS = new Set(['darwin', 'linux', 'win32']); +const SUPPORTED_NATIVE_ARCHITECTURES = new Set(['arm64', 'x64']); +const PROXY_ENV_KEYS = [ + 'HTTPS_PROXY', + 'https_proxy', + 'HTTP_PROXY', + 'http_proxy', + 'ALL_PROXY', + 'all_proxy', +] as const; +const POWERSHELL_PROXY_SETUP = '$proxyUrl = @($env:HTTPS_PROXY, $env:https_proxy, ' + + '$env:HTTP_PROXY, $env:http_proxy, $env:ALL_PROXY, $env:all_proxy) ' + + '| Where-Object { $_ -and $_.Trim() } | Select-Object -First 1; ' + + 'if ($proxyUrl) { $webProxy = [System.Net.WebProxy]::new($proxyUrl); ' + + '$webProxy.Credentials = [System.Net.CredentialCache]::DefaultNetworkCredentials; ' + + '[System.Net.WebRequest]::DefaultWebProxy = $webProxy }; '; +const DEFAULT_INSTALL_TIMEOUT_MS = 15 * 60_000; +const VERIFICATION_TIMEOUT_MS = 30_000; + +export interface AgentInstallCommand { + executable: string; + args: string[]; + display: string; +} + +export interface AgentInstallEnvironment { + platform?: NodeJS.Platform; + arch?: string; + nodeVersion?: string; + commandExists?: (executable: string) => boolean; + proxy?: string; + installTimeoutMs?: number; +} + +export type AgentInstallRunner = (command: AgentInstallCommand) => Promise; + +interface AgentInstallRunOptions { + proxy?: string; + timeoutMs: number; +} + +class AgentInstallTimeoutError extends Error { + constructor(readonly timeoutMs: number) { + super(`Timed out after ${Math.ceil(timeoutMs / 1000)} seconds.`); + this.name = 'AgentInstallTimeoutError'; + } +} + +function defaultCommandExists(executable: string, platform: NodeJS.Platform): boolean { + const command = platform === 'win32' ? 'where.exe' : executable; + const args = platform === 'win32' ? [executable] : ['--version']; + return spawnSync(command, args, { stdio: 'ignore', timeout: 5_000 }).status === 0; +} + +export function getAgentInstallIssue( + agent: AgentId, + environment: AgentInstallEnvironment = {}, +): string | undefined { + const platform = environment.platform ?? process.platform; + const arch = environment.arch ?? process.arch; + const nodeVersion = environment.nodeVersion ?? process.versions.node; + const commandExists = environment.commandExists + ?? ((executable: string) => defaultCommandExists(executable, platform)); + + if (!SUPPORTED_PLATFORMS.has(platform)) { + return `Automated installation is not supported on ${platform}.`; + } + if (agent !== 'pi' && !SUPPORTED_NATIVE_ARCHITECTURES.has(arch)) { + return `Automated installation is not supported on ${platform}/${arch}.`; + } + if (agent === 'pi') { + const [major = 0, minor = 0] = nodeVersion.replace(/^v/, '').split('.').map(Number); + if (major < 22 || (major === 22 && minor < 19)) { + return `Pi requires Node.js 22.19 or newer (current: ${nodeVersion}).`; + } + } + const requirements = agent === 'claude-code' || agent === 'grok' + ? platform === 'win32' ? ['powershell.exe'] : ['bash', 'curl'] + : agent === 'hermes' + ? platform === 'win32' ? ['powershell.exe'] : ['bash', 'curl', 'git'] + : platform === 'win32' ? ['cmd.exe', 'npm'] : ['npm']; + const missing = requirements.filter(executable => !commandExists(executable)); + if (missing.length > 0) return `Required command not found on PATH: ${missing.join(', ')}.`; + return undefined; +} + +function getScriptInstallCommand( + agent: ScriptAgentId, + platform: NodeJS.Platform, +): AgentInstallCommand { + if (platform === 'win32') { + const url = WINDOWS_INSTALLER_URLS[agent]; + if (agent !== 'hermes') { + const script = POWERSHELL_PROXY_SETUP + + `$installer = Invoke-RestMethod '${url}'; ` + + '& ([scriptblock]::Create($installer))'; + return { + executable: 'powershell.exe', + args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], + display: `irm ${url} | iex`, + }; + } + const stages = HERMES_WINDOWS_STAGES.map(stage => `'${stage}'`).join(', '); + const childCommand = `& { ${POWERSHELL_PROXY_SETUP}` + + '& $env:MMX_HERMES_INSTALLER -Stage $env:MMX_HERMES_STAGE ' + + '-NonInteractive -SkipComputerUse -HermesHome $env:MMX_HERMES_HOME ' + + '-InstallDir $env:MMX_HERMES_INSTALL_DIR }'; + const script = "$ErrorActionPreference = 'Stop'; " + + POWERSHELL_PROXY_SETUP + + "$hermesHome = if ($env:HERMES_HOME) { $env:HERMES_HOME } " + + "else { Join-Path $env:USERPROFILE '.hermes' }; " + + "$installDir = Join-Path $hermesHome 'hermes-agent'; " + + "$installer = Join-Path ([IO.Path]::GetTempPath()) " + + "('hermes-install-{0}.ps1' -f [guid]::NewGuid()); " + + `Invoke-WebRequest -UseBasicParsing -Uri '${url}' -OutFile $installer; ` + + `try { foreach ($stage in @(${stages})) { ` + + '$env:MMX_HERMES_INSTALLER = $installer; $env:MMX_HERMES_STAGE = $stage; ' + + '$env:MMX_HERMES_HOME = $hermesHome; $env:MMX_HERMES_INSTALL_DIR = $installDir; ' + + '& powershell.exe -NoProfile -ExecutionPolicy Bypass ' + + `-Command '${childCommand}'; ` + + '$code = $LASTEXITCODE; if ($code -ne 0) { ' + + 'throw "Hermes installer stage $stage failed with exit code $code." } } } ' + + 'finally { Remove-Item -LiteralPath $installer -Force -ErrorAction SilentlyContinue }'; + return { + executable: 'powershell.exe', + args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], + display: script, + }; + } + + const url = INSTALLER_URLS[agent]; + if (agent === 'hermes') { + const stages = HERMES_POSIX_STAGES.join(' '); + const script = 'set -e; installer=$(mktemp); trap \'rm -f "$installer"\' EXIT; ' + + `curl -fsSL ${url} -o "$installer"; ` + + `for stage in ${stages}; do ` + + 'bash "$installer" --stage "$stage" --non-interactive; done'; + return { + executable: 'bash', + args: ['-c', script], + display: script, + }; + } + + const display = `curl -fsSL ${url} | bash`; + return { + executable: 'bash', + args: ['-c', `set -o pipefail; ${display}`], + display, + }; +} + +export function getAgentInstallCommand( + agent: AgentId, + platform: NodeJS.Platform = process.platform, +): AgentInstallCommand { + if (agent === 'claude-code' || agent === 'grok' || agent === 'hermes') { + return getScriptInstallCommand(agent, platform); + } + + const args = [ + 'install', + '-g', + ...(agent === 'pi' ? ['--ignore-scripts', '--engine-strict'] : []), + NPM_PACKAGES[agent], + ]; + const display = `npm ${args.join(' ')}`; + if (platform === 'win32') { + return { + executable: 'cmd.exe', + args: ['/d', '/s', '/c', display], + display, + }; + } + return { executable: 'npm', args, display }; +} + +export function getAgentVerificationCommand( + agent: AgentId, + platform: NodeJS.Platform = process.platform, +): AgentInstallCommand { + if (agent === 'claude-code' || agent === 'grok' || agent === 'hermes') { + const executable = agent === 'claude-code' ? 'claude' : agent; + const display = `${executable} --version`; + if (platform === 'win32') { + const fallback = agent === 'claude-code' + ? "& (Join-Path $env:USERPROFILE '.local\\bin\\claude.exe') --version" + : agent === 'grok' + ? "$bin = if ($env:GROK_BIN_DIR) { $env:GROK_BIN_DIR } else { Join-Path $env:USERPROFILE '.grok\\bin' }; & (Join-Path $bin 'grok.exe') --version" + : "$home = if ($env:HERMES_HOME) { $env:HERMES_HOME } else { Join-Path $env:USERPROFILE '.hermes' }; $exe = Join-Path $home 'bin\\hermes.exe'; $cmd = Join-Path $home 'bin\\hermes.cmd'; if (Test-Path $exe) { & $exe --version } elseif (Test-Path $cmd) { & $cmd --version } else { exit 127 }"; + return { + executable: 'powershell.exe', + args: ['-NoProfile', '-Command', fallback], + display, + }; + } + const fallback = agent === 'claude-code' + ? 'command -v claude >/dev/null 2>&1 && exec claude --version; ' + + 'exec "$HOME/.local/bin/claude" --version' + : agent === 'grok' + ? 'command -v grok >/dev/null 2>&1 && exec grok --version; exec "${GROK_BIN_DIR:-$HOME/.grok/bin}/grok" --version' + : 'command -v hermes >/dev/null 2>&1 && exec hermes --version; ' + + 'for bin in "$HOME/.local/bin/hermes" /usr/local/bin/hermes "${PREFIX:+$PREFIX/bin/hermes}"; ' + + 'do [ -n "$bin" ] && [ -x "$bin" ] && exec "$bin" --version; done; exit 127'; + return { + executable: 'bash', + args: ['-c', fallback], + display, + }; + } + + const display = `${AGENT_EXECUTABLES[agent]} --version`; + if (platform === 'win32') { + return { + executable: 'cmd.exe', + args: ['/d', '/s', '/c', display], + display, + }; + } + return { executable: AGENT_EXECUTABLES[agent], args: ['--version'], display }; +} + +function installerEnvironment(proxy: string | undefined): NodeJS.ProcessEnv { + const env = { ...process.env }; + const hasEnvironmentProxy = PROXY_ENV_KEYS.some(key => Boolean(env[key]?.trim())); + if (proxy && !hasEnvironmentProxy) { + env.HTTPS_PROXY = proxy; + env.https_proxy = proxy; + env.HTTP_PROXY = proxy; + env.http_proxy = proxy; + } + return env; +} + +function childIsRunning(child: ChildProcess): boolean { + return child.pid !== undefined && child.exitCode === null && child.signalCode === null; +} + +function terminateProcessTree(child: ChildProcess, force = false): void { + if (child.pid === undefined) return; + if (process.platform === 'win32') { + const result = spawnSync( + 'taskkill.exe', + ['/pid', String(child.pid), '/t', '/f'], + { stdio: 'ignore', windowsHide: true, timeout: 5_000 }, + ); + if (result.status === 0) return; + } else { + try { + process.kill(-child.pid!, force ? 'SIGKILL' : 'SIGTERM'); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') return; + } + } + + if (childIsRunning(child)) { + try { + child.kill(force ? 'SIGKILL' : 'SIGTERM'); + } catch { + // The process exited between the running check and the signal. + } + } +} + +async function runAgentInstallCommand( + command: AgentInstallCommand, + options: AgentInstallRunOptions, +): Promise { + return new Promise((resolvePromise, reject) => { + const child = spawn(command.executable, command.args, { + detached: process.platform !== 'win32', + env: installerEnvironment(options.proxy), + stdio: ['inherit', process.stderr, 'inherit'], + }); + let settled = false; + const signals = ['SIGHUP', 'SIGINT', 'SIGTERM'] as const; + const signalHandlers = new Map void>(); + const removeSignalHandlers = () => { + for (const [signal, handler] of signalHandlers) { + process.removeListener(signal, handler); + } + }; + const onSignal = (signal: NodeJS.Signals) => { + removeSignalHandlers(); + terminateProcessTree(child, true); + if (process.listenerCount(signal) === 0) process.kill(process.pid, signal); + }; + for (const signal of signals) { + const handler = () => onSignal(signal); + signalHandlers.set(signal, handler); + process.prependOnceListener(signal, handler); + } + + const timeout = setTimeout(() => { + if (settled) return; + terminateProcessTree(child, true); + child.unref(); + settled = true; + removeSignalHandlers(); + reject(new AgentInstallTimeoutError(options.timeoutMs)); + }, options.timeoutMs); + timeout.unref(); + + const finish = (callback: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + removeSignalHandlers(); + callback(); + }; + child.once('error', error => finish(() => reject(error))); + child.once('exit', code => finish(() => resolvePromise(code ?? 1))); + }); +} + +export async function installAgent( + agent: AgentId, + options: AgentInstallEnvironment & { runner?: AgentInstallRunner } = {}, +): Promise { + const issue = getAgentInstallIssue(agent, options); + if (issue) { + throw new CLIError( + `Cannot install ${agent}: ${issue}`, + ExitCode.GENERAL, + 'Leave it unselected and continue with configuration only.', + ); + } + const command = getAgentInstallCommand(agent, options.platform); + const verificationCommand = getAgentVerificationCommand(agent, options.platform); + const networkHint = 'Check internet access, DNS, firewall, and proxy settings, then retry.'; + const manualHint = agent === 'claude-code' || agent === 'grok' || agent === 'hermes' + ? `Run the official installer manually:\n${command.display}` + : `Run the installer manually:\n${command.display}\nFor npm permission errors, see: ` + + 'https://docs.npmjs.com/resolving-eacces-permissions-errors-when-installing-packages-globally'; + const hint = `${networkHint}\n${manualHint}`; + const installRunner = options.runner + ?? ((candidate: AgentInstallCommand) => runAgentInstallCommand(candidate, { + proxy: options.proxy, + timeoutMs: options.installTimeoutMs ?? DEFAULT_INSTALL_TIMEOUT_MS, + })); + + let exitCode: number; + try { + exitCode = await installRunner(command); + } catch (error) { + if (error instanceof AgentInstallTimeoutError) { + throw new CLIError( + `Installation for ${agent} timed out after ${Math.ceil(error.timeoutMs / 1000)} seconds.`, + ExitCode.TIMEOUT, + `The installer was stopped. ${hint}`, + ); + } + const detail = error instanceof Error ? ` ${error.message}` : ''; + throw new CLIError( + `Could not start the installer for ${agent}.${detail}`, + ExitCode.GENERAL, + hint, + ); + } + if (exitCode !== 0) { + throw new CLIError( + `Failed to install ${agent} (installer exited with code ${exitCode}).`, + ExitCode.GENERAL, + hint, + ); + } + + const verificationRunner = options.runner + ?? ((candidate: AgentInstallCommand) => runAgentInstallCommand(candidate, { + proxy: options.proxy, + timeoutMs: VERIFICATION_TIMEOUT_MS, + })); + let verificationExitCode: number; + try { + verificationExitCode = await verificationRunner(verificationCommand); + } catch (error) { + if (error instanceof AgentInstallTimeoutError) { + throw new CLIError( + `Installed ${agent}, but ${verificationCommand.display} timed out after ` + + `${Math.ceil(error.timeoutMs / 1000)} seconds.`, + ExitCode.TIMEOUT, + `Open a new terminal and verify the installation manually:\n${verificationCommand.display}`, + ); + } + const detail = error instanceof Error ? ` ${error.message}` : ''; + throw new CLIError( + `Installed ${agent}, but could not start ${verificationCommand.display}.${detail}`, + ExitCode.GENERAL, + `Open a new terminal and verify the installation manually:\n${verificationCommand.display}`, + ); + } + if (verificationExitCode !== 0) { + throw new CLIError( + `Installed ${agent}, but ${verificationCommand.display} exited with code ${verificationExitCode}.`, + ExitCode.GENERAL, + `Open a new terminal and verify the installation manually:\n${verificationCommand.display}`, + ); + } +} diff --git a/src/agent/verify.ts b/src/agent/verify.ts index 69c185c..5f95dbc 100644 --- a/src/agent/verify.ts +++ b/src/agent/verify.ts @@ -5,16 +5,156 @@ import { endpointsForRegion } from './configurator'; import type { AgentVerification } from './types'; import type { Region } from '../config/schema'; +const PROXY_ENV_KEYS = [ + 'HTTPS_PROXY', + 'https_proxy', + 'HTTP_PROXY', + 'http_proxy', + 'ALL_PROXY', + 'all_proxy', +] as const; + +function networkErrorDetails(error: unknown): { code?: string; text: string } { + let code: string | undefined; + const text: string[] = []; + const pending: unknown[] = [error]; + const seen = new Set(); + + while (pending.length > 0 && seen.size < 10) { + const current = pending.shift(); + if (typeof current === 'string') { + text.push(current); + continue; + } + if (typeof current !== 'object' || current === null || seen.has(current)) continue; + seen.add(current); + + if (current instanceof Error) { + text.push(current.name, current.message); + } + const record = current as Record; + if (!code && typeof record.code === 'string') code = record.code.toUpperCase(); + if (record.cause !== undefined) pending.push(record.cause); + if (Array.isArray(record.errors)) pending.push(...record.errors); + } + + return { code, text: text.join(' ').toLowerCase() }; +} + +function networkVerificationError( + error: unknown, + endpoint: string, + timeoutSeconds: number, + proxyConfigured: boolean, + readingResponse = false, +): CLIError { + const host = new URL(endpoint).hostname; + const details = networkErrorDetails(error); + const isTimeout = details.code === 'ETIMEDOUT' + || details.code === 'UND_ERR_CONNECT_TIMEOUT' + || details.text.includes('timeouterror') + || details.text.includes('aborterror') + || details.text.includes('timed out'); + const isDns = details.code === 'EAI_AGAIN' + || details.code === 'ENOTFOUND' + || details.text.includes('getaddrinfo'); + const isConnection = details.code === 'ECONNREFUSED' + || details.code === 'ECONNRESET' + || details.code === 'ENETUNREACH'; + const unchanged = 'No agent configuration files were changed.'; + const proxyHint = 'If your network uses a proxy, set HTTPS_PROXY and make sure it is available ' + + 'in the current shell or container.'; + const technicalDetail = details.code ? `\nTechnical detail: ${details.code}.` : ''; + let message = `Could not reach ${host}.`; + let exitCode: ExitCode = ExitCode.NETWORK; + let hint = 'Check your internet connection, DNS, firewall, and proxy, then try again.'; + + if (isTimeout) { + message = proxyConfigured + ? `Connection to ${host} through the configured proxy timed out after ${timeoutSeconds} seconds.` + : `Connection to ${host} timed out after ${timeoutSeconds} seconds.`; + exitCode = ExitCode.TIMEOUT; + hint = proxyConfigured + ? 'Check the proxy address and make sure it is reachable from the current shell or container.' + : `Check your connection, firewall, and selected MiniMax region.\n${proxyHint}`; + } else if (readingResponse) { + message = `Connection to ${host} was interrupted while reading the verification response.`; + hint = `Try again and check your network connection and proxy.`; + } else if (proxyConfigured || details.text.includes('proxy')) { + message = `Could not reach ${host} through the configured proxy.`; + hint = 'Check the proxy address and make sure it is reachable from the current shell or container.'; + } else if (isDns) { + message = `Could not resolve ${host}.`; + hint = `Check DNS and internet access, then try again.\n${proxyHint}`; + } else if (isConnection) { + message = `Could not connect to ${host}.`; + hint = `Check your internet connection, firewall, and selected MiniMax region.\n${proxyHint}`; + } + + return new CLIError(message, exitCode, `${unchanged}\n${hint}${technicalDetail}`); +} + +function httpVerificationError(status: number): CLIError { + const unchanged = 'No agent configuration files were changed.'; + if (status === 401 || status === 403) { + return new CLIError( + `MiniMax rejected the API key (HTTP ${status}).`, + ExitCode.AUTH, + `${unchanged}\nCheck that the API key type and selected MiniMax region are correct.`, + ); + } + if (status === 408 || status === 504) { + return new CLIError( + `MiniMax verification timed out (HTTP ${status}).`, + ExitCode.TIMEOUT, + `${unchanged}\nCheck your connection and try again.`, + ); + } + if (status === 402) { + return new CLIError( + 'MiniMax quota or balance is insufficient (HTTP 402).', + ExitCode.QUOTA, + `${unchanged}\nCheck the quota or balance for this API key.`, + ); + } + if (status === 429) { + return new CLIError( + 'MiniMax verification was rate limited (HTTP 429).', + ExitCode.QUOTA, + `${unchanged}\nWait and try again, or check the quota for this API key.`, + ); + } + if (status >= 500) { + return new CLIError( + `MiniMax is temporarily unavailable (HTTP ${status}).`, + ExitCode.GENERAL, + `${unchanged}\nTry again later.`, + ); + } + return new CLIError( + `MiniMax rejected the agent verification request (HTTP ${status}).`, + ExitCode.GENERAL, + `${unchanged}\nCheck the selected MiniMax region and model.`, + ); +} + export async function verifyAgentCredential(options: { apiKey: string; region: Region; model: string; timeoutSeconds?: number; + proxy?: string; }): Promise { const endpoint = `${endpointsForRegion(options.region).openai}/responses`; + const timeoutSeconds = options.timeoutSeconds ?? 30; + const environmentProxy = PROXY_ENV_KEYS + .map(key => process.env[key]?.trim()) + .find((value): value is string => Boolean(value)); + const bunProxy = environmentProxy ?? options.proxy; + const proxyConfigured = Boolean(environmentProxy || options.proxy); let response: Response; try { - response = await fetch(endpoint, { + const requestOptions: RequestInit & { proxy?: string } = { method: 'POST', headers: { Authorization: `Bearer ${options.apiKey}`, @@ -26,23 +166,22 @@ export async function verifyAgentCredential(options: { max_output_tokens: 16, stream: true, }), - signal: AbortSignal.timeout((options.timeoutSeconds ?? 30) * 1000), - }); + signal: AbortSignal.timeout(timeoutSeconds * 1000), + }; + if (bunProxy && process.versions.bun) requestOptions.proxy = bunProxy; + response = await fetch(endpoint, requestOptions); } catch (error) { - throw new CLIError( - `Could not reach the MiniMax agent endpoint: ${error instanceof Error ? error.message : String(error)}`, - ExitCode.NETWORK, - 'No agent configuration files were changed.', + throw networkVerificationError( + error, + endpoint, + timeoutSeconds, + proxyConfigured, ); } if (!response.ok) { await response.body?.cancel().catch(() => undefined); - throw new CLIError( - `MiniMax rejected the agent verification request (${response.status}).`, - response.status === 401 || response.status === 403 ? ExitCode.AUTH : ExitCode.GENERAL, - 'No agent configuration files were changed. Check --region, --model, and the API key.', - ); + throw httpVerificationError(response.status); } let verified = false; @@ -67,12 +206,8 @@ export async function verifyAgentCredential(options: { break; } } - } catch { - throw new CLIError( - 'Could not read the MiniMax agent verification response.', - ExitCode.NETWORK, - 'No agent configuration files were changed.', - ); + } catch (error) { + throw networkVerificationError(error, endpoint, timeoutSeconds, proxyConfigured, true); } finally { await response.body?.cancel().catch(() => undefined); } diff --git a/src/commands/agent/setup.ts b/src/commands/agent/setup.ts index ef2ffa8..ce8578e 100644 --- a/src/commands/agent/setup.ts +++ b/src/commands/agent/setup.ts @@ -4,8 +4,14 @@ import { defineCommand } from '../../command'; import { applyAgentConfigurations, prepareAgentConfigurations, + withAgentSetupLock, } from '../../agent/configurator'; import { detectAgentsOnPath } from '../../agent/availability'; +import { + getAgentInstallCommand, + getAgentInstallIssue, + installAgent, +} from '../../agent/installer'; import { AGENT_IDS, DEFAULT_MINIMAX_MODEL, @@ -16,6 +22,8 @@ import { type AppliedAgentFile, } from '../../agent/types'; import { verifyAgentCredential } from '../../agent/verify'; +import { readConfigFile } from '../../config/loader'; +import { DOCS_HOSTS, type Config } from '../../config/schema'; import { CLIError } from '../../errors/base'; import { ExitCode } from '../../errors/codes'; import { formatOutput, detectOutputFormat } from '../../output/formatter'; @@ -27,7 +35,6 @@ import { promptSelect, withPromptSpinner, } from '../../utils/prompt'; -import { DOCS_HOSTS, type Config } from '../../config/schema'; import type { GlobalFlags } from '../../types/flags'; const AGENT_ALIASES: Record = { @@ -53,8 +60,38 @@ const AGENT_LABELS: Record = { }; const DEFAULT_INTERACTIVE_AGENTS: AgentId[] = ['claude-code', 'codex']; +interface SelectedAgentSetup extends AgentSetupOptions { + agentsToInstall: AgentId[]; +} + type ApiKeyKind = 'token-plan' | 'paygo'; +interface AgentInstallationDependencies { + getCommand(agent: AgentId): ReturnType; + install(agent: AgentId, options: { proxy?: string }): Promise; + note(options: { title: string; message: string }): Promise; + confirm(options: { message: string }): Promise; +} + +interface MissingAgentSelectionDependencies { + select: typeof promptMultiSelect; + note: typeof promptNote; + getIssue(agent: AgentId): string | undefined; +} + +const AGENT_INSTALLATION_DEPENDENCIES: AgentInstallationDependencies = { + getCommand: getAgentInstallCommand, + install: installAgent, + note: promptNote, + confirm: promptConfirm, +}; + +const MISSING_AGENT_SELECTION_DEPENDENCIES: MissingAgentSelectionDependencies = { + select: promptMultiSelect, + note: promptNote, + getIssue: getAgentInstallIssue, +}; + const API_KEY_CHOICES: Array<{ value: ApiKeyKind; label: string; @@ -139,10 +176,77 @@ function isInteractiveInvocation(flags: GlobalFlags): boolean { return Object.values(flags).every((value) => value === undefined || value === false); } +export async function selectMissingAgentInstallations( + agents: AgentId[], + detectedAgents: Set, + dependencies: MissingAgentSelectionDependencies = MISSING_AGENT_SELECTION_DEPENDENCIES, +): Promise { + const missingAgents = agents.filter((agent) => !detectedAgents.has(agent)); + if (missingAgents.length === 0) return []; + + const unavailable = missingAgents.flatMap((agent) => { + const issue = dependencies.getIssue(agent); + return issue ? [{ agent, issue }] : []; + }); + if (unavailable.length > 0) { + await dependencies.note({ + title: 'Unavailable installers', + message: unavailable.map(({ agent, issue }) => `${AGENT_LABELS[agent]}: ${issue}`).join('\n') + + '\n\nThese agents will remain configuration-only.', + }); + } + const installableAgents = missingAgents.filter( + (agent) => !unavailable.some(candidate => candidate.agent === agent), + ); + if (installableAgents.length === 0) return []; + + const selected = await dependencies.select({ + message: 'Select missing agents to install', + choices: installableAgents.map((agent) => ({ + value: agent, + label: AGENT_LABELS[agent], + })), + initialValues: installableAgents, + required: false, + }); + if (selected === undefined) { + throw new CLIError('Agent setup cancelled.', ExitCode.GENERAL); + } + return uniqueAgents(selected); +} + +export async function installSelectedAgents( + agents: AgentId[], + detectedAgents: Set, + options: { proxy?: string } = {}, + dependencies: AgentInstallationDependencies = AGENT_INSTALLATION_DEPENDENCIES, +): Promise { + for (const agent of agents) { + const command = dependencies.getCommand(agent); + await dependencies.note({ + title: `Install ${AGENT_LABELS[agent]}`, + message: `$ ${command.display}`, + }); + try { + await dependencies.install(agent, options); + detectedAgents.add(agent); + } catch (error) { + const detail = error instanceof CLIError + ? `${error.message}${error.hint ? `\n\n${error.hint}` : ''}` + : error instanceof Error ? error.message : String(error); + await dependencies.note({ title: 'Installation failed', message: detail }); + const continueSetup = await dependencies.confirm({ + message: `Continue and configure ${AGENT_LABELS[agent]} without installing it?`, + }); + if (!continueSetup) throw error; + } + } +} + async function interactiveOptions( config: Config, detectedAgents: Set, -): Promise { +): Promise { const selectedAgents = await promptMultiSelect({ message: 'Select agents to configure', choices: AGENT_IDS.map((agent) => ({ @@ -159,6 +263,9 @@ async function interactiveOptions( } const agents = uniqueAgents(selectedAgents); + const notDetected = agents.filter((agent) => !detectedAgents.has(agent)); + const agentsToInstall = await selectMissingAgentInstallations(agents, detectedAgents); + const selectedRegion = await promptSelect({ message: 'Select your MiniMax service region', choices: [ @@ -200,20 +307,29 @@ async function interactiveOptions( throw new CLIError('A MiniMax API key is required.', ExitCode.USAGE); } - const notDetected = agents.filter((agent) => !detectedAgents.has(agent)); let message = `Configure ${agents.map((agent) => AGENT_LABELS[agent]).join(', ')}? ` + 'mmx will write configuration files.'; - if (notDetected.length > 0) { - message += ` Not detected on PATH: ${notDetected.map((agent) => AGENT_LABELS[agent]).join(', ')}. ` - + 'mmx will still write configuration files for them, but will not download or install them for you.'; + if (agentsToInstall.length > 0) { + message += ` It will first install ${agentsToInstall.map((agent) => AGENT_LABELS[agent]).join(', ')} ` + + 'using their official installers.'; + } + const configurationOnly = notDetected.filter((agent) => !agentsToInstall.includes(agent)); + if (configurationOnly.length > 0) { + message += ` Configuration only: ${configurationOnly.map((agent) => AGENT_LABELS[agent]).join(', ')}.`; } const confirmed = await promptConfirm({ message }); if (!confirmed) throw new CLIError('Agent setup cancelled.', ExitCode.GENERAL); - return { agents, apiKey, region: selectedRegion, model: DEFAULT_MINIMAX_MODEL }; + return { + agents, + agentsToInstall, + apiKey, + region: selectedRegion, + model: DEFAULT_MINIMAX_MODEL, + }; } -function nonInteractiveOptions(flags: GlobalFlags): AgentSetupOptions { +function nonInteractiveOptions(flags: GlobalFlags): SelectedAgentSetup { const positional = flags._positional as string[] | undefined; if (positional?.length) { throw new CLIError( @@ -260,12 +376,18 @@ function nonInteractiveOptions(flags: GlobalFlags): AgentSetupOptions { `Supported models: ${MINIMAX_MODELS.map(candidate => candidate.id).join(', ')}`, ); } - return { agents: selected, apiKey, region: flags.region, model: supportedModel }; + return { + agents: selected, + agentsToInstall: [], + apiKey, + region: flags.region, + model: supportedModel, + }; } export default defineCommand({ name: 'agent setup', - description: 'Configure external coding agents using a MiniMax API key (does not install or launch them)', + description: 'Configure coding agents using a MiniMax API key and optionally install missing agents', usage: 'mmx agent setup [--agent ... | --all] [--api-key ] [--region ]', options: [ { @@ -295,6 +417,7 @@ export default defineCommand({ const options = interactive ? await interactiveOptions(config, detectedAgents) : nonInteractiveOptions(flags); + const configuredProxy = readConfigFile().proxy; let verification: AgentVerification = { region: options.region, @@ -308,6 +431,7 @@ export default defineCommand({ region: options.region, model: options.model, timeoutSeconds: Math.min(config.timeout, 60), + proxy: configuredProxy, }); verification = interactive ? await withPromptSpinner({ message: 'Verifying API key with MiniMax...', @@ -316,8 +440,22 @@ export default defineCommand({ }, verify) : await verify(); } - const prepared = prepareAgentConfigurations(options); - const files = applyAgentConfigurations(prepared, config.dryRun); + const configure = async (lockHeld = false) => { + let prepared = prepareAgentConfigurations(options); + if (!config.dryRun) { + await installSelectedAgents(options.agentsToInstall, detectedAgents, { + proxy: configuredProxy, + }); + if (options.agentsToInstall.length > 0) { + prepared = prepareAgentConfigurations(options); + } + } + return applyAgentConfigurations(prepared, config.dryRun, lockHeld); + }; + const installsAgents = !config.dryRun && options.agentsToInstall.length > 0; + const files = installsAgents + ? await withAgentSetupLock(() => configure(true)) + : await configure(); const format = detectOutputFormat(config.output); console.log(formatAgentSetupResult({ verification, diff --git a/test/agent/configurator.test.ts b/test/agent/configurator.test.ts index 2098e15..8aaf788 100644 --- a/test/agent/configurator.test.ts +++ b/test/agent/configurator.test.ts @@ -4,7 +4,9 @@ import { existsSync, lstatSync, mkdirSync, + mkdtempSync, readFileSync, + readdirSync, rmSync, statSync, symlinkSync, @@ -12,6 +14,7 @@ import { } from 'fs'; import { homedir, tmpdir } from 'os'; import { join } from 'path'; +import { pathToFileURL } from 'url'; import { parse as parseToml } from 'smol-toml'; import { parse as parseYaml } from 'yaml'; @@ -63,7 +66,8 @@ describe('agent configurator', () => { join(home, '.pi', 'agent', 'models.json'), '{"providers":{"minimax-cn":{"headers":{"x-keep":"yes"},"models":[' + '{"id":"keep-model","name":"Keep"},' - + '{"id":"MiniMax-M3","custom":true,"cost":{"currency":"credits"}}]}}}\n', + + '{"id":"MiniMax-M3","custom":true,"cost":{"currency":"credits"},' + + '"compat":{"supportsStrictTools":true}}]}}}\n', ); }); @@ -124,6 +128,10 @@ describe('agent configurator', () => { context_window: 1000000, max_context_window: 1000000, input_modalities: ['text', 'image'], + supported_reasoning_levels: [ + { effort: 'none', description: 'Think-Off' }, + { effort: 'high', description: 'Deep' }, + ], }); expect(codexCatalog.models[1]).toMatchObject({ slug: 'MiniMax-M2.7', @@ -131,8 +139,11 @@ describe('agent configurator', () => { context_window: 204800, max_context_window: 204800, input_modalities: ['text'], + supported_reasoning_levels: [{ effort: 'high', description: 'Always on' }], }); expect(codexCatalog.models[2].slug).toBe('MiniMax-M2.7-highspeed'); + expect(codexCatalog.models[2].supported_reasoning_levels) + .toEqual([{ effort: 'high', description: 'Always on' }]); expect(codexCatalog.models[0].apply_patch_tool_type).toBeUndefined(); const grok = parseToml(readFileSync(join(home, '.grok', 'config.toml'), 'utf8')); @@ -172,6 +183,7 @@ describe('agent configurator', () => { expect(hermes.model.provider).toBe('minimax-cn'); expect(hermes.model.context_length).toBe(1000000); expect(hermes.model.max_tokens).toBe(128000); + expect(hermes.agent.reasoning_overrides['MiniMax-M3']).toBe('none'); expect(Object.keys(hermes.providers['minimax-cn'].models)) .toEqual(['MiniMax-M3', 'MiniMax-M2.7', 'MiniMax-M2.7-highspeed']); expect(readFileSync(join(home, '.hermes', '.env'), 'utf8')) @@ -189,6 +201,11 @@ describe('agent configurator', () => { contextWindow: 1000000, maxTokens: 128000, }); + expect(piModels.providers['minimax-cn'].models[1].thinkingLevelMap).toBeUndefined(); + expect(piModels.providers['minimax-cn'].models[1].compat) + .toEqual({ supportsStrictTools: true, forceAdaptiveThinking: true }); + expect(piModels.providers['minimax-cn'].models[2].thinkingLevelMap).toEqual({ off: null }); + expect(piModels.providers['minimax-cn'].models[3].thinkingLevelMap).toEqual({ off: null }); expect(piModels.providers['minimax-cn'].api).toBe('anthropic-messages'); expect(piModels.providers['minimax-cn'].baseUrl).toBe('https://api.minimaxi.com/anthropic'); expect(piModels.providers['minimax-cn'].models.map((model: { id: string }) => model.id)) @@ -214,6 +231,52 @@ describe('agent configurator', () => { expect(configured.model).toMatchObject({ default: 'MiniMax-M3', provider: 'minimax' }); }); + it('preserves existing Hermes reasoning overrides', () => { + mkdirSync(join(home, '.hermes'), { recursive: true }); + writeFileSync( + join(home, '.hermes', 'config.yaml'), + 'agent:\n reasoning_effort: high\n reasoning_overrides:\n' + + ' keep-model: low\n MiniMax-M3: none\nproviders: {}\n', + ); + + applyAgentConfigurations(prepareAgentConfigurations(setupOptions(['hermes']))); + + const configured = parseYaml(readFileSync(join(home, '.hermes', 'config.yaml'), 'utf8')); + expect(configured.agent.reasoning_effort).toBe('high'); + expect(configured.agent.reasoning_overrides) + .toEqual({ 'keep-model': 'low', 'MiniMax-M3': 'none' }); + }); + + it('preserves settings created by the Grok installer', () => { + mkdirSync(join(home, '.grok'), { recursive: true }); + writeFileSync( + join(home, '.grok', 'config.toml'), + '[cli]\ninstaller = "internal"\nchannel = "stable"\n', + ); + + applyAgentConfigurations(prepareAgentConfigurations(setupOptions(['grok']))); + + const configured = parseToml(readFileSync(join(home, '.grok', 'config.toml'), 'utf8')); + expect(configured.cli).toEqual({ installer: 'internal', channel: 'stable' }); + expect((configured.models as Record).default).toBe('minimax'); + }); + + it('accepts the UTF-8 BOM written by the Windows Grok installer', () => { + mkdirSync(join(home, '.grok'), { recursive: true }); + writeFileSync( + join(home, '.grok', 'config.toml'), + '\ufeff[cli]\ninstaller = "internal"\nchannel = "stable"\n', + ); + + applyAgentConfigurations(prepareAgentConfigurations(setupOptions(['grok']))); + + const source = readFileSync(join(home, '.grok', 'config.toml'), 'utf8'); + expect(source.startsWith('\ufeff')).toBe(false); + const configured = parseToml(source); + expect(configured.cli).toEqual({ installer: 'internal', channel: 'stable' }); + expect((configured.models as Record).default).toBe('minimax'); + }); + it('is idempotent and does not create another backup for unchanged files', () => { const options = setupOptions([...AGENT_IDS]); applyAgentConfigurations(prepareAgentConfigurations(options)); @@ -392,15 +455,66 @@ describe('agent configurator', () => { it('rejects duplicate targets before creating files or backups', () => { const shared = join(home, 'shared-agent-config'); - const prepared = prepareAgentConfigurations(setupOptions( + expect(() => prepareAgentConfigurations(setupOptions( ['claude-code', 'pi'], { env: { CLAUDE_CONFIG_DIR: shared, PI_CODING_AGENT_DIR: shared } }, - )); - - expect(() => applyAgentConfigurations(prepared)).toThrow('Multiple agent configuration paths'); + ))).toThrow('Multiple agent configuration paths'); expect(existsSync(shared)).toBe(false); }); + it('removes the setup lock when installation receives an exit signal', async () => { + if (process.platform === 'win32') return; + const configuratorUrl = pathToFileURL( + join(import.meta.dir, '../../src/agent/configurator.ts'), + ).href; + const installerUrl = pathToFileURL(join(import.meta.dir, '../../src/agent/installer.ts')).href; + for (const [signal, expectedExitCode] of [ + ['SIGHUP', 129], + ['SIGINT', 130], + ['SIGTERM', 143], + ] as const) { + const lockDirectory = mkdtempSync(join(tmpdir(), `mmx-agent-lock-${signal.toLowerCase()}-`)); + const readyPath = join(lockDirectory, 'installer.ready'); + const npm = join(lockDirectory, 'npm'); + writeFileSync(npm, '#!/bin/sh\nprintf ready > "$MMX_INSTALL_READY"\nsleep 30\n'); + chmodSync(npm, 0o755); + const child = Bun.spawn({ + cmd: [ + process.execPath, + '-e', + `import { withAgentSetupLock } from ${JSON.stringify(configuratorUrl)};` + + `import { installAgent } from ${JSON.stringify(installerUrl)};` + + 'await withAgentSetupLock(() => installAgent(' + + "'codex', { commandExists: () => true }));", + ], + env: { + ...process.env, + TMPDIR: lockDirectory, + PATH: `${lockDirectory}:${process.env.PATH ?? ''}`, + MMX_INSTALL_READY: readyPath, + }, + stdout: 'ignore', + stderr: 'ignore', + }); + try { + for (let attempt = 0; attempt < 100 && !existsSync(readyPath); attempt += 1) { + await Bun.sleep(10); + } + expect(existsSync(readyPath)).toBe(true); + expect(readdirSync(lockDirectory).filter(name => name.startsWith('mmx-agent-setup-'))) + .toHaveLength(1); + child.kill(signal); + expect(await child.exited).toBe(expectedExitCode); + expect(readdirSync(lockDirectory).filter(name => name.startsWith('mmx-agent-setup-'))) + .toHaveLength(0); + } finally { + child.kill(); + await child.exited; + rmSync(lockDirectory, { recursive: true, force: true }); + } + } + }); + it('preserves comments on unrelated Claude model picker entries', () => { const settingsPath = join(home, '.claude', 'settings.json'); writeFileSync( diff --git a/test/agent/installer.test.ts b/test/agent/installer.test.ts new file mode 100644 index 0000000..9fdff87 --- /dev/null +++ b/test/agent/installer.test.ts @@ -0,0 +1,419 @@ +import { describe, expect, it } from 'bun:test'; +import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { pathToFileURL } from 'url'; + +import { + getAgentInstallCommand, + getAgentInstallIssue, + getAgentVerificationCommand, + installAgent, + type AgentInstallCommand, +} from '../../src/agent/installer'; + +describe('agent installer', () => { + it('uses official packages and installer scripts without invoking an agent', () => { + expect(getAgentInstallCommand('claude-code', 'linux')).toEqual({ + executable: 'bash', + args: ['-c', 'set -o pipefail; curl -fsSL https://claude.ai/install.sh | bash'], + display: 'curl -fsSL https://claude.ai/install.sh | bash', + }); + expect(getAgentInstallCommand('grok', 'linux')).toEqual({ + executable: 'bash', + args: ['-c', 'set -o pipefail; curl -fsSL https://x.ai/cli/install.sh | bash'], + display: 'curl -fsSL https://x.ai/cli/install.sh | bash', + }); + const hermes = getAgentInstallCommand('hermes', 'linux'); + expect(hermes.executable).toBe('bash'); + expect(hermes.args.join(' ')).toContain('https://hermes-agent.nousresearch.com/install.sh'); + expect(hermes.args.join(' ')).toContain('prerequisites'); + expect(hermes.args.join(' ')).toContain('python-deps'); + expect(hermes.args.join(' ')).toContain('node-deps'); + expect(hermes.args.join(' ')).toContain('--non-interactive'); + expect(hermes.args.join(' ')).not.toContain(' setup '); + expect(hermes.args.join(' ')).not.toContain(' gateway '); + }); + + it('uses each package official installation arguments', () => { + expect(getAgentInstallCommand('codex', 'linux')).toEqual({ + executable: 'npm', + args: ['install', '-g', '@openai/codex'], + display: 'npm install -g @openai/codex', + }); + expect(getAgentInstallCommand('opencode', 'linux')).toEqual({ + executable: 'npm', + args: ['install', '-g', 'opencode-ai'], + display: 'npm install -g opencode-ai', + }); + expect(getAgentInstallCommand('pi', 'linux')).toEqual({ + executable: 'npm', + args: ['install', '-g', '--ignore-scripts', '--engine-strict', '@earendil-works/pi-coding-agent'], + display: 'npm install -g --ignore-scripts --engine-strict @earendil-works/pi-coding-agent', + }); + expect(getAgentInstallCommand('codex', 'win32')).toEqual({ + executable: 'cmd.exe', + args: ['/d', '/s', '/c', 'npm install -g @openai/codex'], + display: 'npm install -g @openai/codex', + }); + const grokWindows = getAgentInstallCommand('grok', 'win32'); + expect(grokWindows.executable).toBe('powershell.exe'); + expect(grokWindows.args.join(' ')).toContain('https://x.ai/cli/install.ps1'); + expect(grokWindows.args.join(' ')).toContain('DefaultWebProxy'); + const claudeWindows = getAgentInstallCommand('claude-code', 'win32'); + expect(claudeWindows.executable).toBe('powershell.exe'); + expect(claudeWindows.args.join(' ')).toContain('https://claude.ai/install.ps1'); + const hermesWindows = getAgentInstallCommand('hermes', 'win32'); + expect(hermesWindows.executable).toBe('powershell.exe'); + expect(hermesWindows.args.join(' ')).toContain("'dependencies'"); + expect(hermesWindows.args.join(' ')).toContain("'system-packages'"); + expect(hermesWindows.args.join(' ')).toContain("'node-deps'"); + expect(hermesWindows.args.join(' ')).toContain("'platform-sdks'"); + expect(hermesWindows.args.join(' ')).toContain('-NonInteractive -SkipComputerUse'); + expect(hermesWindows.args.join(' ')).not.toContain("'configure'"); + expect(hermesWindows.args.join(' ')).not.toContain("'gateway'"); + expect(hermesWindows.args.join(' ').match(/DefaultWebProxy/g)?.length).toBe(2); + }); + + it('verifies the installed executable without launching the agent', () => { + const claude = getAgentVerificationCommand('claude-code', 'linux'); + expect(claude.executable).toBe('bash'); + expect(claude.args.join(' ')).toContain('$HOME/.local/bin/claude'); + expect(claude.display).toBe('claude --version'); + expect(getAgentVerificationCommand('pi', 'win32')).toEqual({ + executable: 'cmd.exe', + args: ['/d', '/s', '/c', 'pi --version'], + display: 'pi --version', + }); + expect(getAgentVerificationCommand('grok', 'linux').display).toBe('grok --version'); + expect(getAgentVerificationCommand('hermes', 'win32').display).toBe('hermes --version'); + }); + + it('preflights platform, architecture, commands, and Node requirements', () => { + const commandsExist = { commandExists: () => true }; + expect(getAgentInstallIssue('pi', { + ...commandsExist, + nodeVersion: '18.20.8', + })).toContain('Node.js 22.19 or newer'); + expect(getAgentInstallIssue('pi', { + ...commandsExist, + nodeVersion: '22.19.0', + })).toBeUndefined(); + expect(getAgentInstallIssue('codex', { + ...commandsExist, + platform: 'freebsd', + })).toContain('not supported on freebsd'); + expect(getAgentInstallIssue('opencode', { + ...commandsExist, + arch: 'riscv64', + })).toContain('riscv64'); + expect(getAgentInstallIssue('claude-code', { + commandExists: () => false, + })).toContain('bash'); + expect(getAgentInstallIssue('claude-code', { + ...commandsExist, + nodeVersion: '18.20.8', + })).toBeUndefined(); + expect(getAgentInstallIssue('codex', { + commandExists: () => false, + })).toContain('npm'); + expect(getAgentInstallIssue('hermes', commandsExist)).toBeUndefined(); + expect(getAgentInstallIssue('hermes', { + ...commandsExist, + platform: 'darwin', + arch: 'x64', + })).toBeUndefined(); + expect(getAgentInstallIssue('hermes', { + ...commandsExist, + platform: 'darwin', + arch: 'arm64', + })).toBeUndefined(); + expect(getAgentInstallIssue('grok', commandsExist)).toBeUndefined(); + expect(getAgentInstallIssue('grok', { + platform: 'linux', + commandExists: executable => executable !== 'curl', + })).toContain('curl'); + expect(getAgentInstallIssue('hermes', { + platform: 'linux', + commandExists: executable => executable !== 'bash', + })).toContain('bash'); + expect(getAgentInstallIssue('hermes', { + platform: 'linux', + commandExists: executable => executable !== 'git', + })).toContain('git'); + }); + + it('does not run an installer that fails preflight', async () => { + let ran = false; + await expect(installAgent('pi', { + nodeVersion: '20.19.0', + commandExists: () => true, + runner: async () => { + ran = true; + return 0; + }, + })).rejects.toThrow('Cannot install pi'); + expect(ran).toBe(false); + }); + + it('runs the resolved command and accepts a successful exit', async () => { + const received: AgentInstallCommand[] = []; + await installAgent('codex', { + platform: 'linux', + commandExists: () => true, + runner: async (command) => { + received.push(command); + return 0; + }, + }); + + expect(received).toEqual([ + getAgentInstallCommand('codex', 'linux'), + getAgentVerificationCommand('codex', 'linux'), + ]); + }); + + it('installs and verifies an official script-based agent', async () => { + const received: AgentInstallCommand[] = []; + await installAgent('grok', { + platform: 'linux', + commandExists: () => true, + runner: async (command) => { + received.push(command); + return 0; + }, + }); + + expect(received).toEqual([ + getAgentInstallCommand('grok', 'linux'), + getAgentVerificationCommand('grok', 'linux'), + ]); + }); + + it('reports a failed install with the manual command and npm permission help', async () => { + try { + await installAgent('opencode', { commandExists: () => true, runner: async () => 17 }); + throw new Error('Expected installAgent to throw'); + } catch (error) { + expect(error).toBeInstanceOf(Error); + const failure = error as Error & { hint?: string }; + expect(failure.message).toContain('installer exited with code 17'); + expect(failure.hint).toContain('npm install -g opencode-ai'); + expect(failure.hint).toContain('resolving-eacces-permissions-errors'); + } + }); + + it('reports when the installer executable cannot be started', async () => { + await expect(installAgent('codex', { + commandExists: () => true, + runner: async () => { throw new Error('command not found'); }, + })).rejects.toThrow('Could not start the installer for codex. command not found'); + }); + + it('does not report success when the installed executable fails verification', async () => { + let call = 0; + await expect(installAgent('pi', { + nodeVersion: '22.19.0', + commandExists: () => true, + runner: async () => { + call += 1; + return call === 1 ? 0 : 9; + }, + })).rejects.toThrow('pi --version exited with code 9'); + }); + + it('keeps installer output off stdout', async () => { + if (process.platform === 'win32') return; + const directory = mkdtempSync(join(tmpdir(), 'mmx-installer-output-')); + try { + for (const executable of ['npm', 'codex']) { + const path = join(directory, executable); + writeFileSync(path, `#!/bin/sh\necho "${executable} output"\n`); + chmodSync(path, 0o755); + } + const moduleUrl = pathToFileURL(join(import.meta.dir, '../../src/agent/installer.ts')).href; + const child = Bun.spawn({ + cmd: [ + process.execPath, + '-e', + `import { installAgent } from ${JSON.stringify(moduleUrl)}; await installAgent('codex');`, + ], + env: { ...process.env, PATH: `${directory}:${process.env.PATH ?? ''}` }, + stdout: 'pipe', + stderr: 'pipe', + }); + const stdoutPromise = new Response(child.stdout).text(); + const stderrPromise = new Response(child.stderr).text(); + expect(await child.exited).toBe(0); + expect(await stdoutPromise).toBe(''); + expect(await stderrPromise).toContain('npm output'); + expect(await stderrPromise).toContain('codex output'); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + + it('passes a configured proxy to installers without overriding an environment proxy', async () => { + if (process.platform === 'win32') return; + const directory = mkdtempSync(join(tmpdir(), 'mmx-installer-proxy-')); + const capture = join(directory, 'proxy.txt'); + try { + const npm = join(directory, 'npm'); + const codex = join(directory, 'codex'); + writeFileSync( + npm, + '#!/bin/sh\nprintf "%s\\n%s\\n" "$HTTPS_PROXY" "$HTTP_PROXY" > "$MMX_PROXY_CAPTURE"\n', + ); + writeFileSync(codex, '#!/bin/sh\nexit 0\n'); + chmodSync(npm, 0o755); + chmodSync(codex, 0o755); + const moduleUrl = pathToFileURL(join(import.meta.dir, '../../src/agent/installer.ts')).href; + const env: NodeJS.ProcessEnv = { + ...process.env, + PATH: `${directory}:${process.env.PATH ?? ''}`, + MMX_PROXY_CAPTURE: capture, + }; + for (const key of [ + 'HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy', + ]) delete env[key]; + const child = Bun.spawn({ + cmd: [ + process.execPath, + '-e', + `import { installAgent } from ${JSON.stringify(moduleUrl)}; ` + + "await installAgent('codex', { proxy: 'http://config-proxy.example:8080' });", + ], + env, + stdout: 'ignore', + stderr: 'ignore', + }); + expect(await child.exited).toBe(0); + expect(readFileSync(capture, 'utf8')).toBe( + 'http://config-proxy.example:8080\nhttp://config-proxy.example:8080\n', + ); + + env.HTTPS_PROXY = 'http://environment-proxy.example:8080'; + const precedenceChild = Bun.spawn({ + cmd: [ + process.execPath, + '-e', + `import { installAgent } from ${JSON.stringify(moduleUrl)}; ` + + "await installAgent('codex', { proxy: 'http://config-proxy.example:8080' });", + ], + env, + stdout: 'ignore', + stderr: 'ignore', + }); + expect(await precedenceChild.exited).toBe(0); + expect(readFileSync(capture, 'utf8').split('\n')[0]).toBe( + 'http://environment-proxy.example:8080', + ); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + + it('times out and terminates the installer process tree', async () => { + if (process.platform === 'win32') return; + const directory = mkdtempSync(join(tmpdir(), 'mmx-installer-timeout-')); + const pidPath = join(directory, 'child.pid'); + try { + const npm = join(directory, 'npm'); + writeFileSync( + npm, + '#!/bin/sh\ntrap \'\' TERM\nsleep 30 &\nprintf "%s" "$!" > "$MMX_CHILD_PID"\nwait\n', + ); + chmodSync(npm, 0o755); + const moduleUrl = pathToFileURL(join(import.meta.dir, '../../src/agent/installer.ts')).href; + const child = Bun.spawn({ + cmd: [ + process.execPath, + '-e', + `import { installAgent } from ${JSON.stringify(moduleUrl)}; ` + + "try { await installAgent('codex', { commandExists: () => true, installTimeoutMs: 200 }); process.exit(2); } " + + 'catch (error) { if (error?.exitCode !== 5 || !error?.hint?.includes(' + + "'installer was stopped')) process.exit(3); }", + ], + env: { + ...process.env, + PATH: `${directory}:${process.env.PATH ?? ''}`, + MMX_CHILD_PID: pidPath, + }, + stdout: 'ignore', + stderr: 'ignore', + }); + expect(await child.exited).toBe(0); + expect(existsSync(pidPath)).toBe(true); + const descendantPid = Number(readFileSync(pidPath, 'utf8')); + let running = true; + for (let attempt = 0; attempt < 100 && running; attempt += 1) { + try { + process.kill(descendantPid, 0); + await Bun.sleep(10); + } catch { + running = false; + } + } + expect(running).toBe(false); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + + it('terminates the installer process tree when setup receives an exit signal', async () => { + if (process.platform === 'win32') return; + for (const signal of ['SIGHUP', 'SIGINT', 'SIGTERM'] as const) { + const directory = mkdtempSync(join(tmpdir(), `mmx-installer-${signal.toLowerCase()}-`)); + const pidPath = join(directory, 'child.pid'); + try { + const npm = join(directory, 'npm'); + writeFileSync( + npm, + '#!/bin/sh\ntrap \'\' TERM\nsleep 30 &\nprintf "%s" "$!" > "$MMX_CHILD_PID"\nwait\n', + ); + chmodSync(npm, 0o755); + const moduleUrl = pathToFileURL(join(import.meta.dir, '../../src/agent/installer.ts')).href; + const sigintHandler = signal === 'SIGINT' + ? "process.on('SIGINT', () => process.exit(130)); " + : ''; + const child = Bun.spawn({ + cmd: [ + process.execPath, + '-e', + `import { installAgent } from ${JSON.stringify(moduleUrl)}; ` + + sigintHandler + + "await installAgent('codex', { commandExists: () => true });", + ], + env: { + ...process.env, + PATH: `${directory}:${process.env.PATH ?? ''}`, + MMX_CHILD_PID: pidPath, + }, + stdout: 'ignore', + stderr: 'ignore', + }); + for (let attempt = 0; attempt < 100 && !existsSync(pidPath); attempt += 1) { + await Bun.sleep(10); + } + expect(existsSync(pidPath)).toBe(true); + child.kill(signal); + const expectedExitCode = signal === 'SIGHUP' ? 129 : signal === 'SIGINT' ? 130 : 143; + expect(await child.exited).toBe(expectedExitCode); + + const descendantPid = Number(readFileSync(pidPath, 'utf8')); + let running = true; + for (let attempt = 0; attempt < 100 && running; attempt += 1) { + try { + process.kill(descendantPid, 0); + await Bun.sleep(10); + } catch { + running = false; + } + } + expect(running).toBe(false); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + } + }); +}); diff --git a/test/agent/verify.test.ts b/test/agent/verify.test.ts index f56ca73..ba6a836 100644 --- a/test/agent/verify.test.ts +++ b/test/agent/verify.test.ts @@ -1,12 +1,63 @@ -import { afterEach, describe, expect, it } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { verifyAgentCredential } from '../../src/agent/verify'; +import { CLIError } from '../../src/errors/base'; +import { ExitCode } from '../../src/errors/codes'; + +const PROXY_ENV_KEYS = [ + 'HTTPS_PROXY', + 'https_proxy', + 'HTTP_PROXY', + 'http_proxy', + 'ALL_PROXY', + 'all_proxy', +] as const; + +function fetchFailure(code: string, message: string): Error { + const error = new TypeError('fetch failed') as TypeError & { cause?: unknown }; + error.cause = Object.assign(new Error(message), { code }); + return error; +} + +async function captureVerificationError(overrides: { + proxy?: string; + region?: 'cn' | 'global'; + timeoutSeconds?: number; +} = {}): Promise { + try { + await verifyAgentCredential({ + apiKey: 'sensitive-test-value', + region: overrides.region ?? 'global', + model: 'MiniMax-M3', + timeoutSeconds: overrides.timeoutSeconds, + proxy: overrides.proxy, + }); + } catch (error) { + expect(error).toBeInstanceOf(CLIError); + return error as CLIError; + } + throw new Error('Expected verification to fail.'); +} describe('agent credential verification', () => { const originalFetch = globalThis.fetch; + const originalProxyEnvironment = new Map(); + + beforeEach(() => { + for (const key of PROXY_ENV_KEYS) { + originalProxyEnvironment.set(key, process.env[key]); + delete process.env[key]; + } + }); afterEach(() => { globalThis.fetch = originalFetch; + for (const key of PROXY_ENV_KEYS) { + const value = originalProxyEnvironment.get(key); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + originalProxyEnvironment.clear(); }); it('does not echo an API key reflected by an upstream error', async () => { @@ -44,6 +95,43 @@ describe('agent credential verification', () => { expect(result.status).toBe('ok'); }); + it('passes a configured proxy to Bun fetch', async () => { + let proxy: string | undefined; + globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => { + proxy = (init as RequestInit & { proxy?: string }).proxy; + return new Response('data: {"type":"response.created","response":' + + '{"id":"resp_test","model":"MiniMax-M3"}}\n\n'); + }) as unknown as typeof fetch; + + await verifyAgentCredential({ + apiKey: 'sensitive-test-value', + region: 'global', + model: 'MiniMax-M3', + proxy: 'http://proxy.example:8080', + }); + + expect(proxy).toBe('http://proxy.example:8080'); + }); + + it('keeps environment proxy precedence over a configured proxy', async () => { + let proxy: string | undefined; + process.env.HTTPS_PROXY = 'http://environment-proxy.example:8080'; + globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => { + proxy = (init as RequestInit & { proxy?: string }).proxy; + return new Response('data: {"type":"response.created","response":' + + '{"id":"resp_test","model":"MiniMax-M3"}}\n\n'); + }) as unknown as typeof fetch; + + await verifyAgentCredential({ + apiKey: 'sensitive-test-value', + region: 'global', + model: 'MiniMax-M3', + proxy: 'http://config-proxy.example:8080', + }); + + expect(proxy).toBe('http://environment-proxy.example:8080'); + }); + it('rejects an HTTP success without a Responses API event', async () => { const mockFetch = async () => new Response('data: verification started\n\n'); globalThis.fetch = mockFetch as unknown as typeof fetch; @@ -54,4 +142,119 @@ describe('agent credential verification', () => { model: 'MiniMax-M3', })).rejects.toThrow('invalid agent verification response'); }); + + it('explains DNS failures without exposing the generic fetch message', async () => { + globalThis.fetch = (async () => { + throw fetchFailure('EAI_AGAIN', 'getaddrinfo EAI_AGAIN api.minimaxi.com'); + }) as unknown as typeof fetch; + + const caught = await captureVerificationError({ region: 'cn' }); + + expect(caught.message).toBe('Could not resolve api.minimaxi.com.'); + expect(caught.message).not.toContain('fetch failed'); + expect(caught.exitCode).toBe(ExitCode.NETWORK); + expect(caught.hint).toContain('Check DNS and internet access'); + expect(caught.hint).toContain('Technical detail: EAI_AGAIN.'); + }); + + it('explains connection timeouts and uses the timeout exit code', async () => { + const timeout = new Error('The operation was aborted due to timeout'); + timeout.name = 'TimeoutError'; + globalThis.fetch = (async () => { + throw timeout; + }) as unknown as typeof fetch; + + const caught = await captureVerificationError({ timeoutSeconds: 12 }); + + expect(caught.message).toBe( + 'Connection to api.minimax.io timed out after 12 seconds.', + ); + expect(caught.exitCode).toBe(ExitCode.TIMEOUT); + expect(caught.hint).toContain('selected MiniMax region'); + }); + + it('preserves timeout guidance when an aborted response stream fails', async () => { + const timeout = new Error('The operation was aborted'); + timeout.name = 'AbortError'; + globalThis.fetch = (async () => new Response(new ReadableStream({ + start(controller) { + controller.error(timeout); + }, + }))) as unknown as typeof fetch; + + const caught = await captureVerificationError({ timeoutSeconds: 8 }); + + expect(caught.message).toBe( + 'Connection to api.minimax.io timed out after 8 seconds.', + ); + expect(caught.exitCode).toBe(ExitCode.TIMEOUT); + }); + + it('uses response-reading guidance for other stream failures', async () => { + globalThis.fetch = (async () => new Response(new ReadableStream({ + start(controller) { + controller.error(fetchFailure('ECONNRESET', 'connection reset')); + }, + }))) as unknown as typeof fetch; + + const caught = await captureVerificationError(); + + expect(caught.message).toContain('interrupted while reading the verification response'); + expect(caught.message).not.toContain('Could not reach'); + expect(caught.exitCode).toBe(ExitCode.NETWORK); + }); + + it('maps HTTP timeout responses to timeout guidance', async () => { + globalThis.fetch = (async () => new Response(null, { status: 504 })) as unknown as typeof fetch; + + const caught = await captureVerificationError(); + + expect(caught.message).toBe('MiniMax verification timed out (HTTP 504).'); + expect(caught.exitCode).toBe(ExitCode.TIMEOUT); + expect(caught.hint).toContain('Check your connection and try again'); + }); + + it('tells the user to retry a temporary MiniMax service failure', async () => { + globalThis.fetch = (async () => new Response(null, { status: 503 })) as unknown as typeof fetch; + + const caught = await captureVerificationError(); + + expect(caught.message).toBe('MiniMax is temporarily unavailable (HTTP 503).'); + expect(caught.hint).toContain('Try again later'); + }); + + it('maps HTTP 402 to quota guidance', async () => { + globalThis.fetch = (async () => new Response(null, { status: 402 })) as unknown as typeof fetch; + + const caught = await captureVerificationError(); + + expect(caught.message).toContain('quota or balance is insufficient'); + expect(caught.exitCode).toBe(ExitCode.QUOTA); + }); + + it('explains refused connections and points to network controls', async () => { + globalThis.fetch = (async () => { + throw fetchFailure('ECONNREFUSED', 'connect ECONNREFUSED'); + }) as unknown as typeof fetch; + + const caught = await captureVerificationError(); + + expect(caught.message).toBe('Could not connect to api.minimax.io.'); + expect(caught.hint).toContain('internet connection, firewall'); + expect(caught.hint).toContain('set HTTPS_PROXY'); + }); + + it('explains connection failures through a configured proxy', async () => { + globalThis.fetch = (async () => { + throw fetchFailure('ECONNREFUSED', 'connect ECONNREFUSED 127.0.0.1:10801'); + }) as unknown as typeof fetch; + + const caught = await captureVerificationError({ + proxy: 'http://127.0.0.1:10801', + }); + + expect(caught.message).toContain('through the configured proxy'); + expect(caught.hint).toContain('Check the proxy address'); + expect(caught.hint).toContain('current shell or container'); + }); }); diff --git a/test/commands/agent/setup-install.test.ts b/test/commands/agent/setup-install.test.ts new file mode 100644 index 0000000..5b8a6b5 --- /dev/null +++ b/test/commands/agent/setup-install.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'bun:test'; + +import { + installSelectedAgents, + selectMissingAgentInstallations, +} from '../../../src/commands/agent/setup'; +import { CLIError } from '../../../src/errors/base'; +import type { AgentId } from '../../../src/agent/types'; + +describe('agent setup installation flow', () => { + it('offers only selected agents that are missing and defaults them on', async () => { + const selected = await selectMissingAgentInstallations( + ['claude-code', 'codex', 'pi'], + new Set(['codex']), + { + select: async (options) => { + expect(options.choices.map(choice => choice.value)).toEqual(['claude-code', 'pi']); + expect(options.initialValues).toEqual(['claude-code', 'pi']); + expect(options.required).toBe(false); + return ['pi']; + }, + note: async () => {}, + getIssue: () => undefined, + }, + ); + + expect(selected).toEqual(['pi']); + }); + + it('allows the user to install none of the missing agents', async () => { + const selected = await selectMissingAgentInstallations( + ['codex'], + new Set(), + { select: async () => [], note: async () => {}, getIssue: () => undefined }, + ); + expect(selected).toEqual([]); + }); + + it('treats cancelling the installation list as cancelling setup', async () => { + await expect(selectMissingAgentInstallations( + ['codex'], + new Set(), + { select: async () => undefined, note: async () => {}, getIssue: () => undefined }, + )).rejects.toThrow('Agent setup cancelled.'); + }); + + it('does not prompt when every selected agent is already installed', async () => { + let prompted = false; + const selected = await selectMissingAgentInstallations( + ['codex'], + new Set(['codex']), + { + select: async () => { + prompted = true; + return []; + }, + note: async () => {}, + getIssue: () => undefined, + }, + ); + expect(selected).toEqual([]); + expect(prompted).toBe(false); + }); + + it('explains incompatible installers before showing only installable choices', async () => { + let note = ''; + const selected = await selectMissingAgentInstallations( + ['claude-code', 'pi'], + new Set(), + { + select: async (options) => { + expect(options.choices.map(choice => choice.value)).toEqual(['claude-code']); + return ['claude-code']; + }, + note: async ({ message }) => { note = message; }, + getIssue: agent => agent === 'pi' ? 'Pi requires Node.js 22.19 or newer.' : undefined, + }, + ); + expect(selected).toEqual(['claude-code']); + expect(note).toContain('Pi requires Node.js 22.19 or newer'); + expect(note).toContain('configuration-only'); + }); + + it('offers Grok and Hermes when their official installers are available', async () => { + const selected = await selectMissingAgentInstallations( + ['grok', 'hermes', 'codex'], + new Set(), + { + select: async (options) => { + expect(options.choices.map(choice => choice.value)).toEqual(['grok', 'hermes', 'codex']); + return ['grok', 'hermes']; + }, + note: async () => {}, + getIssue: () => undefined, + }, + ); + + expect(selected).toEqual(['grok', 'hermes']); + }); + + it('installs only the chosen agents and marks only successful installs detected', async () => { + const installed: AgentId[] = []; + const detected = new Set(); + let proxy: string | undefined; + await installSelectedAgents(['pi'], detected, { proxy: 'http://proxy.example:8080' }, { + getCommand: () => ({ executable: 'npm', args: [], display: 'install pi' }), + install: async (agent, options) => { + installed.push(agent); + proxy = options.proxy; + }, + note: async () => {}, + confirm: async () => false, + }); + + expect(installed).toEqual(['pi']); + expect(proxy).toBe('http://proxy.example:8080'); + expect(detected).toEqual(new Set(['pi'])); + }); + + it('can continue configuration after an installation failure', async () => { + const detected = new Set(); + let confirmation = ''; + await installSelectedAgents(['codex'], detected, {}, { + getCommand: () => ({ executable: 'npm', args: [], display: 'install codex' }), + install: async () => { throw new CLIError('install failed'); }, + note: async () => {}, + confirm: async ({ message }) => { + confirmation = message; + return true; + }, + }); + + expect(confirmation).toContain('Continue and configure Codex'); + expect(detected).toEqual(new Set()); + }); + + it('stops configuration when the user declines after an installation failure', async () => { + const failure = new CLIError('install failed'); + await expect(installSelectedAgents(['codex'], new Set(), {}, { + getCommand: () => ({ executable: 'npm', args: [], display: 'install codex' }), + install: async () => { throw failure; }, + note: async () => {}, + confirm: async () => false, + })).rejects.toBe(failure); + }); +}); diff --git a/test/commands/agent/setup.test.ts b/test/commands/agent/setup.test.ts index 49f7e36..eb3f193 100644 --- a/test/commands/agent/setup.test.ts +++ b/test/commands/agent/setup.test.ts @@ -1,9 +1,10 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; -import { mkdtempSync, rmSync } from 'fs'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import setupCommand from '../../../src/commands/agent/setup'; +import { ExitCode } from '../../../src/errors/codes'; import { registry } from '../../../src/registry'; import type { Config } from '../../../src/config/schema'; import type { GlobalFlags } from '../../../src/types/flags'; @@ -164,6 +165,55 @@ describe('agent setup command', () => { expect(stderr).toContain('not interchangeable'); }); + it('reports an unreachable configured proxy and exits without a generic fetch error', async () => { + const configDir = join(home, '.mmx'); + mkdirSync(configDir, { recursive: true }); + writeFileSync(join(configDir, 'config.json'), JSON.stringify({ + proxy: 'http://127.0.0.1:1', + })); + const child = Bun.spawn({ + cmd: [ + process.execPath, + 'run', + 'src/main.ts', + 'agent', + 'setup', + '--agent', + 'codex', + '--api-key', + 'sk-cp-test-only', + '--region', + 'cn', + '--non-interactive', + '--timeout', + '1', + ], + cwd: process.cwd(), + env: { + ...process.env, + HOME: home, + MMX_CONFIG_DIR: configDir, + MINIMAX_OUTPUT: 'text', + NO_COLOR: '1', + HTTPS_PROXY: '', + https_proxy: '', + HTTP_PROXY: '', + http_proxy: '', + ALL_PROXY: '', + all_proxy: '', + }, + stdout: 'ignore', + stderr: 'pipe', + }); + const stderr = await new Response(child.stderr).text(); + + const acceptableExitCodes: number[] = [ExitCode.NETWORK, ExitCode.TIMEOUT]; + expect(acceptableExitCodes).toContain(await child.exited); + expect(stderr).toContain('configured proxy'); + expect(stderr).toContain('No agent configuration files were changed.'); + expect(stderr).not.toContain('fetch failed'); + }); + it('keeps non-interactive text output free of prompt colors', async () => { const output = await captureConsoleLog(() => setupCommand.execute( testConfig({ output: 'text', noColor: false }),