From 592bd9302c52137f933805c13552dacf95bfbe48 Mon Sep 17 00:00:00 2001 From: jellyjelly814 Date: Mon, 12 Oct 2026 16:07:04 +0800 Subject: [PATCH] feat: add Oh my Pi setup and default text to M3.1 Flash Preview --- README.md | 14 +++- README_CN.md | 14 +++- SDK.md | 6 +- skill/SKILL.md | 8 +- src/agent/availability.ts | 1 + src/agent/configurator.ts | 111 +++++++++++++++++++++++++ src/agent/installer.ts | 35 ++++++-- src/agent/types.ts | 4 +- src/commands/agent/setup.ts | 5 +- src/commands/text/chat.ts | 7 +- src/commands/text/repl.ts | 7 +- src/config/models.ts | 1 + src/sdk/text/index.ts | 3 +- test/agent/availability.test.ts | 6 ++ test/agent/installer.test.ts | 31 ++++++- test/agent/oh-my-pi.test.ts | 130 ++++++++++++++++++++++++++++++ test/commands/agent/setup.test.ts | 14 ++++ test/commands/text/chat.test.ts | 2 +- test/sdk/text.test.ts | 50 +++++++----- 19 files changed, 399 insertions(+), 50 deletions(-) create mode 100644 src/config/models.ts create mode 100644 test/agent/oh-my-pi.test.ts diff --git a/README.md b/README.md index ace15c4b..8ea3f13f 100644 --- a/README.md +++ b/README.md @@ -70,9 +70,11 @@ derives the npm package version from that tag. See [the release guide](docs/rele ### `mmx text` +`text chat` and `text repl` default to `MiniMax-M3.1-Flash-Preview`. An explicit `--model` or saved `default-text-model` takes precedence. This preview currently requires M Plan or MiniMax Code; with a pay-as-you-go key, select `MiniMax-M3` explicitly. See the [model guide](https://platform.minimax.io/docs/guides/text-generation#agent). + ```bash mmx text chat --message "Write a poem" -mmx text chat --model MiniMax-M3 --message "Hello" --stream +mmx text chat --model MiniMax-M3.1-Flash-Preview --message "Hello" --stream mmx text chat --system "You are a coding assistant" --message "Fizzbuzz in Go" mmx text chat --message "user:Hi" --message "assistant:Hey!" --message "How are you?" cat messages.json | mmx text chat --messages-file - --output json @@ -196,22 +198,28 @@ Credential priority: `--api-key` flag > OAuth (config) > `api_key` (config). mmx quota mmx config show mmx config set --key region --value cn -mmx config set --key default-text-model --value MiniMax-M3 +mmx config set --key default-text-model --value MiniMax-M3.1-Flash-Preview mmx config export-schema | jq . ``` ### `mmx agent setup` -Configure MiniMax for Claude Code, Codex, Grok Build, OpenCode, Hermes, or 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, 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. ```bash mmx agent setup mmx agent setup --agent codex --agent claude-code --api-key "$MINIMAX_API_KEY" --region global mmx agent setup --all --api-key "$MINIMAX_API_KEY" --region cn --output json +mmx agent setup --agent oh-my-pi --api-key "$MINIMAX_API_KEY" --region global +# --agent omp is an alias for --agent oh-my-pi ``` +`agent setup` defaults to `MiniMax-M3.1-Flash-Preview` for subscription keys and to `MiniMax-M3` for pay-as-you-go keys (`sk-api-...`). In non-interactive mode, `--model` overrides this selection. + 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. +Oh my Pi uses its native `~/.omp/agent/models.yml` and `config.yml` (existing `.yaml` files are updated in place). Configuration honors `OMP_PROFILE` / `PI_PROFILE`, `PI_CONFIG_DIR`, and the default-profile `PI_CODING_AGENT_DIR` override. Run `omp` once to migrate legacy JSON-only configuration before using setup. Interactive installation uses the official prebuilt-binary installer, so it does not require Bun or npm. + ### `mmx update` ```bash diff --git a/README_CN.md b/README_CN.md index ed99c4df..7c2b0848 100644 --- a/README_CN.md +++ b/README_CN.md @@ -65,9 +65,11 @@ mmx quota ### `mmx text` +`text chat` 和 `text repl` 默认使用 `MiniMax-M3.1-Flash-Preview`;显式指定的 `--model` 或已保存的 `default-text-model` 优先。该预览模型目前仅通过 M Plan 和 MiniMax Code 提供;使用按量付费 Key 时,请显式选择 `MiniMax-M3`。详见[模型调用文档](https://platform.minimax.cn/docs/guides/text-generation#agent)。 + ```bash mmx text chat --message "写一首诗" -mmx text chat --model MiniMax-M3 --message "你好" --stream +mmx text chat --model MiniMax-M3.1-Flash-Preview --message "你好" --stream mmx text chat --system "你是编程助手" --message "用 Go 写 Fizzbuzz" mmx text chat --message "user:你好" --message "assistant:嗨!" --message "你叫什么名字?" cat messages.json | mmx text chat --messages-file - --output json @@ -170,22 +172,28 @@ Global 与中国两个 region,选用能通过的那个。 mmx quota mmx config show mmx config set --key region --value cn -mmx config set --key default-text-model --value MiniMax-M3 +mmx config set --key default-text-model --value MiniMax-M3.1-Flash-Preview mmx config export-schema | jq . ``` ### `mmx agent setup` -一键为 Claude Code、Codex、Grok Build、OpenCode、Hermes 或 Pi 配置 MiniMax,并保留配置文件中的其他设置。不带选项时进入交互式向导;带选项时自动使用非交互模式,便于脚本调用。 +一键为 Claude Code、Codex、Grok Build、OpenCode、Hermes、Pi 或 [Oh my Pi](https://github.com/can1357/oh-my-pi) 配置 MiniMax,并保留配置文件中的其他设置。不带选项时进入交互式向导;带选项时自动使用非交互模式,便于脚本调用。 ```bash mmx agent setup mmx agent setup --agent codex --agent claude-code --api-key "$MINIMAX_API_KEY" --region global mmx agent setup --all --api-key "$MINIMAX_API_KEY" --region cn --output json +mmx agent setup --agent oh-my-pi --api-key "$MINIMAX_API_KEY" --region cn +# --agent omp 是 --agent oh-my-pi 的别名 ``` +`agent setup` 对订阅 Key 默认使用 `MiniMax-M3.1-Flash-Preview`,对按量付费 Key(`sk-api-...`)默认使用 `MiniMax-M3`。非交互模式下可用 `--model` 覆盖该选择。 + 写入前会验证 Key 和所选区域;修改已有文件时会创建备份。可用 `--dry-run` 预览且不会发起联网请求。在交互式向导中,兼容且未在 `PATH` 中检测到的 Agent 会出现在第二个安装多选列表,并通过官方软件包或安装脚本安装。Hermes 仅执行官方核心 CLI 安装阶段,跳过可选系统软件包、Node 工作区、浏览器、计算机控制、初始化和网关阶段。安装完成后,mmx 会保留安装器生成的其他设置并写入 MiniMax 配置。平台或依赖检查不通过的 Agent 会说明原因并保持仅配置。非交互调用仍只写配置,且该命令不会启动 Agent。 +Oh my Pi 使用原生 `~/.omp/agent/models.yml` 和 `config.yml`(已有 `.yaml` 文件会原地更新),遵循 `OMP_PROFILE` / `PI_PROFILE`、`PI_CONFIG_DIR` 和默认 profile 的 `PI_CODING_AGENT_DIR` 配置。仅有旧版 JSON 配置时,请先运行一次 `omp` 完成迁移。交互式安装使用官方预编译二进制安装器,无需 Bun 或 npm。 + ### `mmx update` ```bash diff --git a/SDK.md b/SDK.md index b6dbb5d2..eea1acfd 100644 --- a/SDK.md +++ b/SDK.md @@ -25,16 +25,18 @@ You can also omit `apiKey` if it's already configured via `mmx config set api-ke ### Text +When `model` is omitted, the SDK uses `MiniMax-M3.1-Flash-Preview`. This preview currently requires M Plan or MiniMax Code; with a pay-as-you-go key, pass `model: 'MiniMax-M3'` explicitly. Thinking is enabled by default with `max` effort; no extra request fields are required. See the [model guide](https://platform.minimax.io/docs/guides/text-generation#agent). + ```typescript const response = await sdk.text.chat({ - model: 'MiniMax-M3', + model: 'MiniMax-M3.1-Flash-Preview', messages: [{ role: 'user', content: 'Hello!' }], max_tokens: 4096, }); // Streaming const stream = await sdk.text.chat({ - model: 'MiniMax-M3', + model: 'MiniMax-M3.1-Flash-Preview', messages: [{ role: 'user', content: 'Write a poem' }], stream: true, }); diff --git a/skill/SKILL.md b/skill/SKILL.md index d1d173c4..13bc1ced 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -46,7 +46,7 @@ Always use these flags in non-interactive (agent/CI) contexts: ### text chat -Chat completion. Default model: `MiniMax-M3`. +Chat completion. Default model: `MiniMax-M3.1-Flash-Preview`. An explicit `--model` or saved `default-text-model` takes precedence. This preview currently requires M Plan or MiniMax Code; with a pay-as-you-go key, use `--model MiniMax-M3`. Thinking is enabled by default with `max` effort. See the [model guide](https://platform.minimax.io/docs/guides/text-generation#agent). ```bash mmx text chat --message [flags] @@ -57,7 +57,7 @@ mmx text chat --message [flags] | `--message ` | string, **required**, repeatable | Message text. Prefix with `role:` to set role (e.g. `"system:You are helpful"`, `"user:Hello"`) | | `--messages-file ` | string | JSON file with messages array. Use `-` for stdin | | `--system ` | string | System prompt | -| `--model ` | string | Model ID (default: `MiniMax-M3`) | +| `--model ` | string | Model ID (default: `MiniMax-M3.1-Flash-Preview`) | | `--max-tokens ` | number | Max tokens (default: 4096) | | `--temperature ` | number | Sampling temperature (0.0, 1.0] | | `--top-p ` | number | Nucleus sampling threshold | @@ -381,7 +381,7 @@ Set per-modality defaults so you don't need `--model` every time: ```bash # Set defaults -mmx config set --key default-text-model --value MiniMax-M3 +mmx config set --key default-text-model --value MiniMax-M3.1-Flash-Preview mmx config set --key default-speech-model --value speech-2.8-hd mmx config set --key default-video-model --value MiniMax-Hailuo-2.3 @@ -391,7 +391,7 @@ mmx speech synthesize --text "Hello" --out hello.mp3 mmx video generate --prompt "Ocean waves" # --model still overrides per-call -mmx text chat --model MiniMax-M3 --message "Hello" +mmx text chat --model MiniMax-M3.1-Flash-Preview --message "Hello" ``` **Resolution priority**: `--model` flag > config default > hardcoded fallback. diff --git a/src/agent/availability.ts b/src/agent/availability.ts index 8b99561d..b648bde5 100644 --- a/src/agent/availability.ts +++ b/src/agent/availability.ts @@ -10,6 +10,7 @@ const AGENT_EXECUTABLES: Record = { opencode: ['opencode'], hermes: ['hermes'], pi: ['pi'], + 'oh-my-pi': ['omp'], }; const AGENT_RUNTIME_MARKERS: Partial> = { diff --git a/src/agent/configurator.ts b/src/agent/configurator.ts index dbd42bab..15dca7c2 100644 --- a/src/agent/configurator.ts +++ b/src/agent/configurator.ts @@ -72,6 +72,45 @@ function pathFromOverride(value: string | undefined, fallback: string, home: str return isAbsolute(value) ? value : resolve(home, value); } +function ohMyPiConfigPaths(home: string, env: NodeJS.ProcessEnv): string[] { + // Match OMP's profile/env precedence, including an explicitly empty OMP_PROFILE. + const profile = (env.OMP_PROFILE ?? env.PI_PROFILE)?.trim(); + const root = join(home, env.PI_CONFIG_DIR || '.omp'); + const validProfile = (name: string) => /^[a-z0-9][a-z0-9._-]{0,63}$/.test(name) + && !name.endsWith('.') + && !/^(?:con|prn|aux|nul|com[0-9]|lpt[0-9])(?:\..*)?$/i.test(name); + let dir: string; + if (profile && profile !== 'default') { + if (!validProfile(profile)) { + throw new CLIError('Invalid Oh my Pi profile name.', ExitCode.USAGE); + } + dir = join(root, 'profiles', profile, 'agent'); + } else { + // A parent OMP process propagates its named profile's directory. Selecting + // the default profile must discard that inherited value, as OMP itself does. + const inheritedProfile = env.PI_PROFILE?.trim(); + const inheritedDir = inheritedProfile && inheritedProfile !== 'default' && validProfile(inheritedProfile) + ? join(root, 'profiles', inheritedProfile, 'agent') : undefined; + const override = env.PI_CODING_AGENT_DIR === inheritedDir ? undefined : env.PI_CODING_AGENT_DIR; + dir = override ? resolve(override) : join(root, 'agent'); + } + return ['models', 'config'].map(name => { + const yml = join(dir, `${name}.yml`); + const yaml = join(dir, `${name}.yaml`); + if (existsSync(yml)) return yml; + if (existsSync(yaml)) return yaml; + const legacy = join(dir, name === 'models' ? 'models.json' : 'settings.json'); + if (existsSync(legacy)) { + throw new CLIError( + 'Oh my Pi has legacy JSON configuration that needs migration.', + ExitCode.GENERAL, + 'Run omp once with the same profile/environment to migrate it to YAML, then retry. No files were changed.', + ); + } + return yml; + }); +} + function configPathsForAgent( agent: AgentId, options: AgentSetupOptions, @@ -115,6 +154,8 @@ function configPathsForAgent( ); return [join(dir, 'models.json'), join(dir, 'settings.json')]; } + case 'oh-my-pi': + return ohMyPiConfigPaths(home, env); } } @@ -938,6 +979,73 @@ function preparePi(options: AgentSetupOptions, paths: string[]): PreparedAgentFi ]; } +function prepareOhMyPi(options: AgentSetupOptions, paths: string[]): PreparedAgentFile[] { + const providerId = options.region === 'cn' ? 'minimax-cn' : 'minimax'; + const baseUrl = endpointsForRegion(options.region).anthropic; + return paths.map((path, index) => { + const existing = readPrepared(path); + const label = `Oh my Pi ${basename(path)}`; + const document = parseDocument(existing.before ?? '{}\n'); + if (document.errors.length > 0) { + throw new CLIError(`${label} is not valid YAML.`, ExitCode.GENERAL, INVALID_CONFIG_HINT); + } + if (document.contents === null) document.set(index === 0 ? 'providers' : 'modelRoles', document.createNode({})); + const root = document.toJS() as unknown; + assertObject(root, `${label} must contain an object at its root.`); + if (index === 0) { + if (root.providers !== undefined) assertObject(root.providers, `${label} providers must be an object.`); + const provider = root.providers?.[providerId]; + if (provider !== undefined) assertObject(provider, `${label} provider must be an object.`); + const models = provider?.models; + if (models !== undefined && !Array.isArray(models)) { + throw new CLIError(`${label} models must be an array.`, ExitCode.GENERAL, INVALID_CONFIG_HINT); + } + const entries = (models ?? []) as unknown[]; + for (const entry of entries) assertObject(entry, `${label} model entries must be objects.`); + const prefix = ['providers', providerId]; + for (const [key, value] of Object.entries({ baseUrl, apiKey: options.apiKey, api: 'anthropic-messages', auth: 'apiKey' })) { + document.setIn([...prefix, key], value); + } + if (models === undefined) document.setIn([...prefix, 'models'], document.createNode([])); + let appendIndex = entries.length; + for (const model of MINIMAX_MODELS) { + const found = entries.findIndex(entry => (entry as Record).id === model.id); + const modelPath = [...prefix, 'models', found >= 0 ? found : appendIndex++]; + const definition = { + id: model.id, + name: model.id, + api: 'anthropic-messages', + baseUrl, + reasoning: true, + input: [...model.input], + contextWindow: model.contextWindow, + maxTokens: model.maxTokens, + ...(model.id === 'MiniMax-M3.1-Flash-Preview' ? { + thinking: { + mode: 'anthropic-adaptive', + efforts: ['low', 'medium', 'high', 'xhigh', 'max'], + defaultLevel: 'max', + requiresEffort: true, + }, + } : {}), + }; + if (found < 0) { + document.setIn(modelPath, document.createNode(definition)); + } else { + for (const [key, value] of Object.entries(definition)) { + document.setIn([...modelPath, key], document.createNode(value)); + } + } + } + } else { + if (root.modelRoles !== undefined) assertObject(root.modelRoles, `${label} modelRoles must be an object.`); + const effort = options.model === 'MiniMax-M3.1-Flash-Preview' ? ':max' : ''; + document.setIn(['modelRoles', 'default'], `${providerId}/${options.model}${effort}`); + } + return { agent: 'oh-my-pi', path, ...existing, after: document.toString() }; + }); +} + export function prepareAgentConfigurations(options: AgentSetupOptions): PreparedAgentFile[] { const prepared: PreparedAgentFile[] = []; for (const agent of options.agents) { @@ -961,6 +1069,9 @@ export function prepareAgentConfigurations(options: AgentSetupOptions): Prepared case 'pi': prepared.push(...preparePi(options, paths)); break; + case 'oh-my-pi': + prepared.push(...prepareOhMyPi(options, paths)); + break; } } assertDistinctConfigurationTargets(prepared); diff --git a/src/agent/installer.ts b/src/agent/installer.ts index 9d78042e..52aefce1 100644 --- a/src/agent/installer.ts +++ b/src/agent/installer.ts @@ -23,12 +23,14 @@ 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', + 'oh-my-pi': 'https://omp.sh/install', }; 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', + 'oh-my-pi': 'https://omp.sh/install.ps1', }; // These mirror the official manifest's non-interactive stages. Setup, gateway, @@ -136,7 +138,7 @@ export function getAgentInstallIssue( return `Pi requires Node.js 22.19 or newer (current: ${nodeVersion}).`; } } - const requirements = agent === 'claude-code' || agent === 'grok' + const requirements = agent === 'claude-code' || agent === 'grok' || agent === 'oh-my-pi' ? platform === 'win32' ? ['powershell.exe'] : ['bash', 'curl'] : agent === 'hermes' ? platform === 'win32' ? ['powershell.exe'] : ['bash', 'curl', 'git'] @@ -153,13 +155,16 @@ function getScriptInstallCommand( if (platform === 'win32') { const url = WINDOWS_INSTALLER_URLS[agent]; if (agent !== 'hermes') { + const binaryFlag = agent === 'oh-my-pi' ? ' -Binary' : ''; const script = POWERSHELL_PROXY_SETUP + `$installer = Invoke-RestMethod '${url}'; ` - + '& ([scriptblock]::Create($installer))'; + + `& ([scriptblock]::Create($installer))${binaryFlag}`; return { executable: 'powershell.exe', args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], - display: `irm ${url} | iex`, + display: agent === 'oh-my-pi' + ? `& ([scriptblock]::Create((irm ${url}))) -Binary` + : `irm ${url} | iex`, }; } const stages = HERMES_WINDOWS_STAGES.map(stage => `'${stage}'`).join(', '); @@ -204,7 +209,7 @@ function getScriptInstallCommand( }; } - const display = `curl -fsSL ${url} | bash`; + const display = `curl -fsSL ${url} | bash${agent === 'oh-my-pi' ? ' -s -- --binary' : ''}`; return { executable: 'bash', args: ['-c', `set -o pipefail; ${display}`], @@ -216,7 +221,7 @@ export function getAgentInstallCommand( agent: AgentId, platform: NodeJS.Platform = process.platform, ): AgentInstallCommand { - if (agent === 'claude-code' || agent === 'grok' || agent === 'hermes') { + if (agent === 'claude-code' || agent === 'grok' || agent === 'hermes' || agent === 'oh-my-pi') { return getScriptInstallCommand(agent, platform); } @@ -241,6 +246,24 @@ export function getAgentVerificationCommand( agent: AgentId, platform: NodeJS.Platform = process.platform, ): AgentInstallCommand { + if (agent === 'oh-my-pi') { + const display = 'omp --version'; + if (platform === 'win32') { + return { + executable: 'powershell.exe', + args: ['-NoProfile', '-Command', + "$bin = if ($env:PI_INSTALL_DIR) { $env:PI_INSTALL_DIR } " + + "else { Join-Path $env:LOCALAPPDATA 'omp' }; " + + "& (Join-Path $bin 'omp.exe') --version"], + display, + }; + } + return { + executable: 'bash', + args: ['-c', 'exec "${PI_INSTALL_DIR:-$HOME/.local/bin}/omp" --version'], + display, + }; + } if (agent === 'claude-code' || agent === 'grok' || agent === 'hermes') { const executable = agent === 'claude-code' ? 'claude' : agent; const display = `${executable} --version`; @@ -391,7 +414,7 @@ export async function installAgent( 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' + const manualHint = agent === 'claude-code' || agent === 'grok' || agent === 'hermes' || agent === 'oh-my-pi' ? `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'; diff --git a/src/agent/types.ts b/src/agent/types.ts index fea3e321..fb9cd01b 100644 --- a/src/agent/types.ts +++ b/src/agent/types.ts @@ -1,4 +1,5 @@ import type { Region } from '../config/schema'; +import { DEFAULT_TEXT_MODEL } from '../config/models'; export const AGENT_IDS = [ 'claude-code', @@ -7,13 +8,14 @@ export const AGENT_IDS = [ 'opencode', 'hermes', 'pi', + 'oh-my-pi', ] as const; export type AgentId = typeof AGENT_IDS[number]; export const MINIMAX_MODELS = [ { - id: 'MiniMax-M3.1-Flash-Preview', + id: DEFAULT_TEXT_MODEL, contextWindow: 524288, maxTokens: 128000, input: ['text', 'image'], diff --git a/src/commands/agent/setup.ts b/src/commands/agent/setup.ts index 8a2423b4..89a1569f 100644 --- a/src/commands/agent/setup.ts +++ b/src/commands/agent/setup.ts @@ -53,6 +53,8 @@ const AGENT_ALIASES: Record = { opencode: 'opencode', hermes: 'hermes', pi: 'pi', + 'oh-my-pi': 'oh-my-pi', + omp: 'oh-my-pi', }; const AGENT_LABELS: Record = { @@ -62,6 +64,7 @@ const AGENT_LABELS: Record = { opencode: 'OpenCode', hermes: 'Hermes Agent', pi: 'Pi', + 'oh-my-pi': 'Oh my Pi (omp)', }; const DEFAULT_INTERACTIVE_AGENTS: AgentId[] = ['claude-code', 'codex']; @@ -488,7 +491,7 @@ export default defineCommand({ options: [ { flag: '--agent ', - description: 'Agent: claude-code, codex, grok/grok-build, opencode, hermes, pi (repeatable)', + description: 'Agent: claude-code, codex, grok/grok-build, opencode, hermes, pi, oh-my-pi/omp (repeatable)', type: 'array', }, { diff --git a/src/commands/text/chat.ts b/src/commands/text/chat.ts index 1c81bc9e..15c51a41 100644 --- a/src/commands/text/chat.ts +++ b/src/commands/text/chat.ts @@ -6,6 +6,7 @@ import { formatOutput, detectOutputFormat } from '../../output/formatter'; import { CLIError } from '../../errors/base'; import { ExitCode } from '../../errors/codes'; import type { Config } from '../../config/schema'; +import { DEFAULT_TEXT_MODEL } from '../../config/models'; import type { GlobalFlags } from '../../types/flags'; import type { ChatMessage, @@ -158,7 +159,7 @@ export default defineCommand({ apiDocs: '/docs/api-reference/text-post', usage: 'mmx text chat --message [flags]', options: [ - { flag: '--model ', description: 'Model ID (default: MiniMax-M3)' }, + { flag: '--model ', description: `Model ID (default: ${DEFAULT_TEXT_MODEL})` }, { flag: '--message ', description: 'Message text (repeatable, prefix role: to set role)', required: true, type: 'array' }, { flag: '--messages-file ', description: 'JSON file with messages array (use - for stdin)' }, { flag: '--system ', description: 'System prompt' }, @@ -170,7 +171,7 @@ export default defineCommand({ ], examples: [ 'mmx text chat --message "What is MiniMax?"', - 'mmx text chat --model MiniMax-M3 --system "You are a coding assistant." --message "Write fizzbuzz in Python"', + `mmx text chat --model ${DEFAULT_TEXT_MODEL} --system "You are a coding assistant." --message "Write fizzbuzz in Python"`, 'mmx text chat --message "Hello" --message "assistant:Hi!" --message "How are you?"', 'cat conversation.json | mmx text chat --messages-file - --stream', 'mmx text chat --message "Hello" --output json', @@ -196,7 +197,7 @@ export default defineCommand({ const model = (flags.model as string) || config.defaultTextModel - || 'MiniMax-M3'; + || DEFAULT_TEXT_MODEL; const format = detectOutputFormat(config.output); const shouldStream = flags.stream === true || ( flags.stream === undefined diff --git a/src/commands/text/repl.ts b/src/commands/text/repl.ts index 756dc603..96842527 100644 --- a/src/commands/text/repl.ts +++ b/src/commands/text/repl.ts @@ -6,6 +6,7 @@ import { CLIError } from '../../errors/base'; import { ExitCode } from '../../errors/codes'; import { isInteractive } from '../../utils/env'; import type { Config } from '../../config/schema'; +import { DEFAULT_TEXT_MODEL } from '../../config/models'; import type { GlobalFlags } from '../../types/flags'; import type { ChatMessage, ChatRequest, StreamEvent } from '../../types/api'; import { writeFileSync } from 'node:fs'; @@ -294,7 +295,7 @@ export default defineCommand({ description: 'Start an interactive multi-turn chat session', usage: 'mmx text repl [flags]', options: [ - { flag: '--model ', description: 'Model ID (default: MiniMax-M3)' }, + { flag: '--model ', description: `Model ID (default: ${DEFAULT_TEXT_MODEL})` }, { flag: '--system ', description: 'System prompt' }, { flag: '--max-tokens ', description: 'Maximum tokens per response (default: 4096)', type: 'number' }, { flag: '--temperature ', description: 'Sampling temperature (0.0, 1.0]', type: 'number' }, @@ -302,7 +303,7 @@ export default defineCommand({ ], examples: [ 'mmx text repl', - 'mmx text repl --model MiniMax-M3 --system "You are a coding assistant."', + `mmx text repl --model ${DEFAULT_TEXT_MODEL} --system "You are a coding assistant."`, 'mmx text repl --temperature 0.7 --max-tokens 8192', ], async run(config: Config, flags: GlobalFlags) { @@ -325,7 +326,7 @@ export default defineCommand({ const state: ReplState = { messages: [], system: flags.system as string | undefined, - model: (flags.model as string) || config.defaultTextModel || 'MiniMax-M3', + model: (flags.model as string) || config.defaultTextModel || DEFAULT_TEXT_MODEL, maxTokens: (flags.maxTokens as number) ?? 4096, temperature: flags.temperature !== undefined ? flags.temperature as number : undefined, topP: flags.topP !== undefined ? flags.topP as number : undefined, diff --git a/src/config/models.ts b/src/config/models.ts new file mode 100644 index 00000000..ee78cac5 --- /dev/null +++ b/src/config/models.ts @@ -0,0 +1 @@ +export const DEFAULT_TEXT_MODEL = 'MiniMax-M3.1-Flash-Preview'; diff --git a/src/sdk/text/index.ts b/src/sdk/text/index.ts index 8d905d4a..fb4cd07d 100644 --- a/src/sdk/text/index.ts +++ b/src/sdk/text/index.ts @@ -3,6 +3,7 @@ import { chatEndpoint } from "../../client/endpoints"; import { ChatRequest, ChatResponse, StreamEvent } from "../../types/api"; import { SDKError } from "../../errors/base"; import { ExitCode } from "../../errors/codes"; +import { DEFAULT_TEXT_MODEL } from "../../config/models"; export class TextSDK extends Client { private async *chatStream(body: Partial): AsyncGenerator { @@ -56,7 +57,7 @@ export class TextSDK extends Client { return { ...params, - model: params.model ?? 'MiniMax-M3', + model: params.model ?? DEFAULT_TEXT_MODEL, max_tokens: params.max_tokens ?? 4096, } as ChatRequest; } diff --git a/test/agent/availability.test.ts b/test/agent/availability.test.ts index ab8d0df7..92fd70ce 100644 --- a/test/agent/availability.test.ts +++ b/test/agent/availability.test.ts @@ -43,6 +43,12 @@ describe('agent availability', () => { } }); + it('detects Oh my Pi separately from Pi', () => { + const root = executableDirectory('omp'); + expect(detectAgentsOnPath({ PATH: root, PATHEXT: '.COM;.EXE;.BAT;.CMD' })) + .toEqual(new Set(['oh-my-pi'])); + }); + it('does not scan the working directory when PATH is missing', () => { const root = executableDirectory('codex'); const originalCwd = process.cwd(); diff --git a/test/agent/installer.test.ts b/test/agent/installer.test.ts index 9fdff87a..5961be57 100644 --- a/test/agent/installer.test.ts +++ b/test/agent/installer.test.ts @@ -89,6 +89,34 @@ describe('agent installer', () => { expect(getAgentVerificationCommand('hermes', 'win32').display).toBe('hermes --version'); }); + it('installs and verifies Oh my Pi with official binaries without requiring Bun or npm', async () => { + for (const platform of ['darwin', 'linux', 'win32'] as const) { + expect(getAgentInstallIssue('oh-my-pi', { + platform, arch: 'arm64', commandExists: command => !['npm', 'bun', 'sh'].includes(command), + })).toBeUndefined(); + const received: AgentInstallCommand[] = []; + await installAgent('oh-my-pi', { + platform, commandExists: () => true, + runner: async command => { received.push(command); return 0; }, + }); + expect(received).toEqual([ + getAgentInstallCommand('oh-my-pi', platform), + getAgentVerificationCommand('oh-my-pi', platform), + ]); + expect(received[0]?.args.join(' ')).toContain('https://omp.sh/install'); + expect(received[0]?.args.join(' ')).toContain(platform === 'win32' ? '-Binary' : '--binary'); + if (platform !== 'win32') expect(received[0]?.args.join(' ')).toContain('| bash -s -- --binary'); + expect(received[1]?.args.join(' ')).toContain('PI_INSTALL_DIR'); + expect(received[1]?.display).toBe('omp --version'); + } + expect(getAgentInstallIssue('oh-my-pi', { + platform: 'linux', commandExists: command => command !== 'curl', + })).toContain('curl'); + expect(getAgentInstallIssue('oh-my-pi', { + platform: 'win32', commandExists: () => false, + })).toContain('powershell.exe'); + }); + it('preflights platform, architecture, commands, and Node requirements', () => { const commandsExist = { commandExists: () => true }; expect(getAgentInstallIssue('pi', { @@ -325,12 +353,13 @@ describe('agent installer', () => { ); chmodSync(npm, 0o755); const moduleUrl = pathToFileURL(join(import.meta.dir, '../../src/agent/installer.ts')).href; + // Allow a cold shell startup before timing out so the descendant exists to verify cleanup. 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); } " + + "try { await installAgent('codex', { commandExists: () => true, installTimeoutMs: 2000 }); process.exit(2); } " + 'catch (error) { if (error?.exitCode !== 5 || !error?.hint?.includes(' + "'installer was stopped')) process.exit(3); }", ], diff --git a/test/agent/oh-my-pi.test.ts b/test/agent/oh-my-pi.test.ts new file mode 100644 index 00000000..71907792 --- /dev/null +++ b/test/agent/oh-my-pi.test.ts @@ -0,0 +1,130 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join, resolve } from 'path'; +import { parse } from 'yaml'; + +import { applyAgentConfigurations, prepareAgentConfigurations } from '../../src/agent/configurator'; +import type { AgentSetupOptions } from '../../src/agent/types'; + +describe('Oh my Pi configuration', () => { + let homeDir: string; + let options: AgentSetupOptions; + + beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), 'mmx-omp-')); + options = { + agents: ['oh-my-pi'], homeDir, env: {}, region: 'global', + apiKey: 'sk-cp-test-only', model: 'MiniMax-M3.1-Flash-Preview', + }; + }); + + afterEach(() => rmSync(homeDir, { recursive: true, force: true })); + + it('writes native YAML for both regions, preserves other models/settings, and is idempotent', () => { + const dir = join(homeDir, '.omp', 'agent'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'models.yaml'), '# keep models comment\nproviders:\n custom:\n apiKey: KEEP\n' + + ' minimax:\n headers: {x-keep: yes}\n models:\n' + + ' - id: keep-model\n name: Keep\n' + + ' - id: MiniMax-M3.1-Flash-Preview\n cost: {input: 1, output: 2, cacheRead: 0, cacheWrite: 0}\n'); + writeFileSync(join(dir, 'config.yaml'), '# keep config comment\ntheme: {dark: titanium}\n' + + 'modelRoles:\n smol: custom/keep-model\n default: old/model\n'); + for (const region of ['global', 'cn'] as const) { + const selected = { ...options, region }; + const prepared = prepareAgentConfigurations(selected); + expect(prepared.map(file => file.path)).toEqual([join(dir, 'models.yaml'), join(dir, 'config.yaml')]); + const applied = applyAgentConfigurations(prepared); + expect(applied.every(file => file.backup)).toBe(true); + const providerId = region === 'cn' ? 'minimax-cn' : 'minimax'; + const host = region === 'cn' ? 'cn' : 'io'; + const modelsText = readFileSync(join(dir, 'models.yaml'), 'utf8'); + const models = parse(modelsText); + const provider = models.providers[providerId]; + expect(provider).toMatchObject({ + api: 'anthropic-messages', auth: 'apiKey', apiKey: options.apiKey, + baseUrl: `https://api.minimax.${host}/anthropic`, + }); + expect(provider.models.find((model: { id: string }) => model.id === options.model)).toMatchObject({ + contextWindow: 524288, + thinking: { + mode: 'anthropic-adaptive', efforts: ['low', 'medium', 'high', 'xhigh', 'max'], + defaultLevel: 'max', requiresEffort: true, + }, + }); + expect(modelsText).toContain('# keep models comment'); + expect(models.providers.custom.apiKey).toBe('KEEP'); + expect(models.providers.minimax.headers['x-keep']).toBe('yes'); + expect(models.providers.minimax.models[0].id).toBe('keep-model'); + expect(models.providers.minimax.models[1].cost.input).toBe(1); + const configText = readFileSync(join(dir, 'config.yaml'), 'utf8'); + expect(configText).toContain('# keep config comment'); + expect(parse(configText)).toMatchObject({ + theme: { dark: 'titanium' }, + modelRoles: { smol: 'custom/keep-model', default: `${providerId}/${options.model}:max` }, + }); + expect(applyAgentConfigurations(prepareAgentConfigurations(selected)).every(file => file.status === 'unchanged')) + .toBe(true); + } + expect(existsSync(join(homeDir, '.pi', 'agent'))).toBe(false); + }); + + it('matches profile and directory precedence without writing during preview', () => { + const scenarios = [ + [{}, join(homeDir, '.omp', 'agent')], + [{ PI_CODING_AGENT_DIR: 'relative-omp' }, resolve('relative-omp')], + [{ PI_CONFIG_DIR: '.custom-omp' }, join(homeDir, '.custom-omp', 'agent')], + [{ OMP_PROFILE: 'work', PI_CODING_AGENT_DIR: '/ignored' }, join(homeDir, '.omp', 'profiles', 'work', 'agent')], + [{ PI_PROFILE: 'legacy' }, join(homeDir, '.omp', 'profiles', 'legacy', 'agent')], + [{ OMP_PROFILE: '', PI_PROFILE: 'legacy' }, join(homeDir, '.omp', 'agent')], + [{ OMP_PROFILE: 'default' }, join(homeDir, '.omp', 'agent')], + [{ OMP_PROFILE: '', PI_PROFILE: 'work', PI_CODING_AGENT_DIR: join(homeDir, '.omp', 'profiles', 'work', 'agent') }, join(homeDir, '.omp', 'agent')], + [{ OMP_PROFILE: 'default', PI_PROFILE: 'work', PI_CODING_AGENT_DIR: join(homeDir, '.omp', 'profiles', 'work', 'agent') }, join(homeDir, '.omp', 'agent')], + [{ OMP_PROFILE: '', PI_PROFILE: 'work', PI_CODING_AGENT_DIR: join(homeDir, 'custom-agent') }, join(homeDir, 'custom-agent')], + ] as const; + for (const [env, dir] of scenarios) { + const files = prepareAgentConfigurations({ ...options, env }); + expect(files.map(file => file.path)).toEqual([join(dir, 'models.yml'), join(dir, 'config.yml')]); + expect(applyAgentConfigurations(files, true).every(file => file.status === 'would-configure')).toBe(true); + } + expect(existsSync(join(homeDir, '.omp'))).toBe(false); + }); + + it('honors canonical yml precedence and an explicit M3 choice', () => { + const dir = join(homeDir, '.omp', 'agent'); + mkdirSync(dir, { recursive: true }); + for (const name of ['models', 'config']) { + writeFileSync(join(dir, `${name}.yml`), '# empty YAML is valid\n'); + writeFileSync(join(dir, `${name}.yaml`), 'ignored: true\n'); + } + const prepared = prepareAgentConfigurations({ ...options, model: 'MiniMax-M3' }); + applyAgentConfigurations(prepared); + expect(parse(readFileSync(join(dir, 'config.yml'), 'utf8')).modelRoles.default).toBe('minimax/MiniMax-M3'); + expect(readFileSync(join(dir, 'config.yaml'), 'utf8')).toBe('ignored: true\n'); + }); + + it('rejects legacy or malformed configuration before changing any files', () => { + const dir = join(homeDir, '.omp', 'agent'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'settings.json'), '{"theme":"keep"}'); + expect(() => prepareAgentConfigurations(options)).toThrow('needs migration'); + expect(existsSync(join(dir, 'models.yml'))).toBe(false); + writeFileSync(join(dir, 'config.yml'), 'modelRoles: []\n'); + expect(() => prepareAgentConfigurations(options)).toThrow('modelRoles must be an object'); + expect(existsSync(join(dir, 'models.yml'))).toBe(false); + writeFileSync(join(dir, 'config.yml'), '{}\n'); + writeFileSync(join(dir, 'models.yml'), 'providers: {minimax: {models: wrong}}\n'); + expect(() => prepareAgentConfigurations(options)).toThrow('models must be an array'); + writeFileSync(join(dir, 'models.yml'), 'providers: [\n apiKey: sk-secret-must-not-leak\n'); + expect(() => prepareAgentConfigurations(options)).toThrow('not valid YAML'); + try { prepareAgentConfigurations(options); } catch (error) { + expect(String(error)).not.toContain('sk-secret-must-not-leak'); + } + }); + + it('rejects invalid profiles before selecting any configuration files', () => { + for (const OMP_PROFILE of ['../outside', 'trailing.', 'CON', 'con.txt']) { + expect(() => prepareAgentConfigurations({ ...options, env: { OMP_PROFILE } })).toThrow('profile'); + } + }); +}); diff --git a/test/commands/agent/setup.test.ts b/test/commands/agent/setup.test.ts index 5263436d..1f5dee3f 100644 --- a/test/commands/agent/setup.test.ts +++ b/test/commands/agent/setup.test.ts @@ -413,6 +413,20 @@ describe('agent setup command', () => { expect(JSON.parse(output).agents).toEqual(['grok']); }); + it('accepts both Oh my Pi names and includes it in --all', async () => { + for (const selection of [{ agent: ['omp', 'oh-my-pi'] }, { all: true }]) { + const output = await captureConsoleLog(() => setupCommand.execute( + testConfig(), + testFlags({ ...selection, apiKey: 'sk-cp-test-secret', region: 'global' }), + )); + const result = JSON.parse(output); + expect(result.agents.filter((agent: string) => agent === 'oh-my-pi')).toHaveLength(1); + expect(result.files.filter((file: { agent: string }) => file.agent === 'oh-my-pi')).toHaveLength(2); + expect(result.verification.model).toBe('MiniMax-M3.1-Flash-Preview'); + expect(output).not.toContain('sk-cp-test-secret'); + } + }); + it('validates --agent values even when --all is present', async () => { await expect(setupCommand.execute( testConfig(), diff --git a/test/commands/text/chat.test.ts b/test/commands/text/chat.test.ts index 2c592b6e..e65405cf 100644 --- a/test/commands/text/chat.test.ts +++ b/test/commands/text/chat.test.ts @@ -96,7 +96,7 @@ describe('text chat command', () => { }); const parsed = JSON.parse(output); - expect(parsed.request.model).toBe('MiniMax-M3'); + expect(parsed.request.model).toBe('MiniMax-M3.1-Flash-Preview'); expect(parsed.request.messages).toHaveLength(1); } finally { console.log = originalLog; diff --git a/test/sdk/text.test.ts b/test/sdk/text.test.ts index 43186517..1c3de987 100644 --- a/test/sdk/text.test.ts +++ b/test/sdk/text.test.ts @@ -10,18 +10,22 @@ describe('MiniMaxSDK.text', () => { server?.close(); }); - it('should chat successfully', async () => { + it('sends the default model and max_tokens when omitted', async () => { + let requestBody: unknown; server = createMockServer({ routes: { - '/anthropic/v1/messages': () => jsonResponse({ - id: 'msg-123', - type: 'message', - role: 'assistant', - content: [{ type: 'text', text: 'Hello!' }], - model: 'MiniMax-M3', - stop_reason: 'end_turn', - usage: { input_tokens: 10, output_tokens: 5 }, - }), + '/anthropic/v1/messages': async (req) => { + requestBody = await req.json(); + return jsonResponse({ + id: 'msg-123', + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'Hello!' }], + model: 'MiniMax-M3.1-Flash-Preview', + stop_reason: 'end_turn', + usage: { input_tokens: 10, output_tokens: 5 }, + }); + }, }, }); @@ -35,9 +39,14 @@ describe('MiniMaxSDK.text', () => { }); expect(result.id).toBe('msg-123'); + expect(requestBody).toMatchObject({ + model: 'MiniMax-M3.1-Flash-Preview', + max_tokens: 4096, + }); }); it('streaming skips empty SSE data events', async () => { + let requestBody: unknown; const chunk = JSON.stringify({ type: 'content_block_delta', index: 0, @@ -46,11 +55,14 @@ describe('MiniMaxSDK.text', () => { server = createMockServer({ routes: { - '/anthropic/v1/messages': () => sseResponse([ - { data: chunk }, - { data: '' }, - { data: chunk }, - ]), + '/anthropic/v1/messages': async (req) => { + requestBody = await req.json(); + return sseResponse([ + { data: chunk }, + { data: '' }, + { data: chunk }, + ]); + }, }, }); @@ -60,6 +72,7 @@ describe('MiniMaxSDK.text', () => { }); const stream = await sdk.text.chat({ + model: 'MiniMax-M3', messages: [{ role: 'user', content: 'Hi' }], stream: true, }); @@ -72,6 +85,7 @@ describe('MiniMaxSDK.text', () => { expect(events.length).toBe(2); expect(events[0].type).toBe('content_block_delta'); expect(events[1].type).toBe('content_block_delta'); + expect(requestBody).toMatchObject({ model: 'MiniMax-M3', stream: true }); }); }); @@ -81,10 +95,4 @@ describe('TextSDK.validateParams', () => { it('throws when messages array is empty', async () => { await expect(sdk.chat({ messages: [] })).rejects.toThrow('At least one message is required'); }); - - it('applies defaults for model and max_tokens', async () => { - await expect( - sdk.chat({ messages: [{ role: 'user', content: 'Hi' }] }), - ).rejects.not.toThrow('At least one message'); - }); });