diff --git a/README.md b/README.md index 38d439e..3033b3a 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,18 @@ mmx config set --key default-text-model --value MiniMax-M3 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. + +```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 +``` + +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. + ### `mmx update` ```bash diff --git a/README_CN.md b/README_CN.md index 90262de..5e485bd 100644 --- a/README_CN.md +++ b/README_CN.md @@ -156,6 +156,18 @@ mmx config set --key default-text-model --value MiniMax-M3 mmx config export-schema | jq . ``` +### `mmx agent setup` + +一键为 Claude Code、Codex、Grok Build、OpenCode、Hermes 或 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 +``` + +写入前会验证 Key 和所选区域;修改已有文件时会创建备份。可用 `--dry-run` 预览且不会发起联网请求。该命令只管理配置文件,不会安装或启动所选 Agent。 + ### `mmx update` ```bash diff --git a/bun.lock b/bun.lock index 3435bcf..ed925c5 100644 --- a/bun.lock +++ b/bun.lock @@ -5,10 +5,15 @@ "": { "name": "minimax-cli", "dependencies": { - "@clack/prompts": "^0.7.0", + "@clack/core": "1.0.0", + "@clack/prompts": "1.0.0", "bun-plugin-dts": "^0.4.0", "es-toolkit": "^1.46.1", + "jsonc-parser": "^3.3.1", + "picocolors": "^1.1.1", + "smol-toml": "^1.8.0", "undici": "^6.21.1", + "yaml": "^2.9.0", }, "devDependencies": { "@eslint/js": "^9.0.0", @@ -20,9 +25,9 @@ }, }, "packages": { - "@clack/core": ["@clack/core@0.3.5", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-5cfhQNH+1VQ2xLQlmzXMqUoiaH0lRBq9/CLW9lTyMbuKLC3+xEK01tHVvyut++mLOn5urSHmkm6I0Lg9MaJSTQ=="], + "@clack/core": ["@clack/core@1.0.0", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-Orf9Ltr5NeiEuVJS8Rk2XTw3IxNC2Bic3ash7GgYeA8LJ/zmSNpSQ/m5UAhe03lA6KFgklzZ5KTHs4OAMA/SAQ=="], - "@clack/prompts": ["@clack/prompts@0.7.0", "", { "dependencies": { "@clack/core": "^0.3.3", "is-unicode-supported": "*", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-0MhX9/B4iL6Re04jPrttDm+BsP8y6mS7byuv0BvXgdXhbV5PdlsHt55dvNsuBCPZ7xq1oTAOOuotR9NFbQyMSA=="], + "@clack/prompts": ["@clack/prompts@1.0.0", "", { "dependencies": { "@clack/core": "1.0.0", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-rWPXg9UaCFqErJVQ+MecOaWsozjaxol4yjnmYcGNipAWzdaWa2x+VJmKfGq7L0APwBohQOYdHC+9RO4qRXej+A=="], "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], @@ -192,6 +197,8 @@ "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], @@ -240,6 +247,8 @@ "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + "smol-toml": ["smol-toml@1.8.0", "", {}, "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ=="], + "string-width": ["string-width@4.2.3", "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "strip-ansi": ["strip-ansi@6.0.1", "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -272,14 +281,14 @@ "y18n": ["y18n@5.0.8", "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + "yargs": ["yargs@17.7.2", "https://registry.npmmirror.com/yargs/-/yargs-17.7.2.tgz", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], "yargs-parser": ["yargs-parser@21.1.1", "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-21.1.1.tgz", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - "@clack/prompts/is-unicode-supported": ["is-unicode-supported@2.1.0", "", { "bundled": true }, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], - "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], diff --git a/package.json b/package.json index 73b5f17..9b3e59a 100644 --- a/package.json +++ b/package.json @@ -40,10 +40,15 @@ "test:watch": "bun test --watch" }, "dependencies": { - "@clack/prompts": "^0.7.0", + "@clack/core": "1.0.0", + "@clack/prompts": "1.0.0", "bun-plugin-dts": "^0.4.0", "es-toolkit": "^1.46.1", - "undici": "^6.21.1" + "jsonc-parser": "^3.3.1", + "picocolors": "^1.1.1", + "smol-toml": "^1.8.0", + "undici": "^6.21.1", + "yaml": "^2.9.0" }, "devDependencies": { "@eslint/js": "^9.0.0", diff --git a/src/agent/availability.ts b/src/agent/availability.ts new file mode 100644 index 0000000..193d4b1 --- /dev/null +++ b/src/agent/availability.ts @@ -0,0 +1,38 @@ +import { accessSync, constants, statSync } from 'fs'; +import { delimiter, join } from 'path'; + +import { AGENT_IDS, type AgentId } from './types'; + +const AGENT_EXECUTABLES: Record = { + 'claude-code': ['claude'], + codex: ['codex'], + grok: ['grok', 'grok-build', 'grok-cli'], + opencode: ['opencode'], + hermes: ['hermes'], + pi: ['pi'], +}; + +export function detectAgentsOnPath(env: NodeJS.ProcessEnv = process.env): Set { + const pathValue = process.platform === 'win32' ? env.PATH ?? env.Path : env.PATH; + if (pathValue === undefined) return new Set(); + const directories = pathValue + .split(delimiter) + .map((directory) => directory.replace(/^"|"$/g, '')); + const extensions = process.platform === 'win32' + ? (env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';') + : ['']; + const accessMode = process.platform === 'win32' ? constants.F_OK : constants.X_OK; + + return new Set(AGENT_IDS.filter((agent) => AGENT_EXECUTABLES[agent].some( + (command) => directories.some((directory) => extensions.some((extension) => { + const candidate = join(directory, `${command}${extension}`); + try { + if (!statSync(candidate).isFile()) return false; + accessSync(candidate, accessMode); + return true; + } catch { + return false; + } + })), + ))); +} diff --git a/src/agent/configurator.ts b/src/agent/configurator.ts new file mode 100644 index 0000000..c6a1e74 --- /dev/null +++ b/src/agent/configurator.ts @@ -0,0 +1,1138 @@ +import { + chmodSync, + closeSync, + existsSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + realpathSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from 'fs'; +import { homedir, tmpdir } from 'os'; +import { basename, dirname, isAbsolute, join, resolve } from 'path'; + +import { + applyEdits, + modify, + parse, + type ParseError, +} from 'jsonc-parser/lib/esm/main.js'; +import { parse as parseToml } from 'smol-toml'; +import { parseDocument } from 'yaml'; + +import { CLIError } from '../errors/base'; +import { ExitCode } from '../errors/codes'; +import { + MINIMAX_MODELS, + type AgentId, + type AgentSetupOptions, + type AppliedAgentFile, + type MiniMaxModelId, + type PreparedAgentFile, +} from './types'; + +const JSON_FORMATTING = { insertSpaces: true, tabSize: 2, eol: '\n' }; +const TOML_KEY_PATTERN = /^[A-Za-z0-9_-]+$/; +const CODEX_MODEL_CATALOG_FILENAME = 'mmx-model-catalog.json'; +const CODEX_MODEL_CATALOG_OWNER = 'mmx agent setup'; +const INVALID_CONFIG_HINT = 'Fix the existing configuration file and retry. No files were changed.'; + +function minimaxModel(modelId: MiniMaxModelId) { + const model = MINIMAX_MODELS.find(candidate => candidate.id === modelId); + if (!model) throw new CLIError(`Unsupported MiniMax model: ${modelId}`, ExitCode.USAGE); + return model; +} + +function claudeModelId(modelId: MiniMaxModelId): string { + return modelId === 'MiniMax-M3' ? `${modelId}[1m]` : modelId; +} + +function grokModelProfile(modelId: MiniMaxModelId): string { + return modelId === 'MiniMax-M3' + ? 'minimax' + : modelId.toLowerCase().replaceAll('.', '-'); +} + +export function endpointsForRegion(region: AgentSetupOptions['region']) { + const host = region === 'cn' ? 'https://api.minimaxi.com' : 'https://api.minimax.io'; + return { + anthropic: `${host}/anthropic`, + openai: `${host}/v1`, + }; +} + +function pathFromOverride(value: string | undefined, fallback: string, home: string): string { + if (!value?.trim()) return fallback; + return isAbsolute(value) ? value : resolve(home, value); +} + +function configPathsForAgent( + agent: AgentId, + options: AgentSetupOptions, +): string[] { + const home = options.homeDir ?? homedir(); + const env = options.env ?? process.env; + + switch (agent) { + case 'claude-code': { + const dir = pathFromOverride(env.CLAUDE_CONFIG_DIR, join(home, '.claude'), home); + return [join(dir, 'settings.json')]; + } + case 'codex': { + const dir = pathFromOverride(env.CODEX_HOME, join(home, '.codex'), home); + return [join(dir, 'config.toml'), join(dir, CODEX_MODEL_CATALOG_FILENAME)]; + } + case 'grok': + return [join(pathFromOverride(env.GROK_HOME, join(home, '.grok'), home), 'config.toml')]; + case 'opencode': { + if (env.OPENCODE_CONFIG?.trim()) { + const customPath = env.OPENCODE_CONFIG.trim(); + return [isAbsolute(customPath) ? customPath : resolve(customPath)]; + } + const xdgConfig = pathFromOverride(env.XDG_CONFIG_HOME, join(home, '.config'), home); + const configDir = join(xdgConfig, 'opencode'); + const jsonPath = join(configDir, 'opencode.json'); + const jsoncPath = join(configDir, 'opencode.jsonc'); + if (existsSync(jsonPath) && existsSync(jsoncPath)) { + throw new CLIError( + `Both OpenCode global config files exist: ${jsonPath} and ${jsoncPath}.`, + ExitCode.GENERAL, + 'Keep one global config file, or set OPENCODE_CONFIG to the file mmx should update.', + ); + } + return [existsSync(jsoncPath) ? jsoncPath : jsonPath]; + } + case 'hermes': { + const dir = pathFromOverride(env.HERMES_HOME, join(home, '.hermes'), home); + return [join(dir, 'config.yaml'), join(dir, '.env')]; + } + case 'pi': { + const dir = pathFromOverride( + env.PI_CODING_AGENT_DIR, + join(home, '.pi', 'agent'), + home, + ); + return [join(dir, 'models.json'), join(dir, 'settings.json')]; + } + } +} + +function readExisting(path: string): string | null { + return existsSync(path) ? readFileSync(path, 'utf8') : null; +} + +function readPrepared(path: string): { targetPath: string; before: string | null } { + const targetPath = writeTarget(path); + return { targetPath, before: readExisting(targetPath) }; +} + +function assertObjectRoot(value: unknown, label: string): asserts value is Record { + assertObject(value, `${label} must contain a JSON object at its root.`); +} + +function assertObject( + value: unknown, + message: string, +): asserts value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new CLIError( + message, + ExitCode.GENERAL, + INVALID_CONFIG_HINT, + ); + } +} + +function parseJsoncObject(source: string, label: string): Record { + const errors: ParseError[] = []; + const root = parse(source, errors, { + allowTrailingComma: true, + disallowComments: false, + }) as unknown; + if (errors.length > 0) { + throw new CLIError( + `${label} is not valid JSON/JSONC.`, + ExitCode.GENERAL, + INVALID_CONFIG_HINT, + ); + } + assertObjectRoot(root, label); + return root; +} + +function updateJsonc( + before: string | null, + label: string, + updates: Array<{ path: Array; value: unknown }>, +): string { + let source = before ?? '{}\n'; + parseJsoncObject(source, label); + + for (const update of updates) { + source = applyEdits(source, modify(source, update.path, update.value, { + formattingOptions: JSON_FORMATTING, + })); + } + return source.endsWith('\n') ? source : `${source}\n`; +} + +interface TomlSection { + bodyStart: number; + end: number; +} + +function findTomlSection(source: string, sectionName: string): TomlSection | undefined { + const lines = source.match(/.*(?:\r?\n|$)/g)?.filter(Boolean) ?? []; + let offset = 0; + let found: TomlSection | undefined; + + for (const line of lines) { + const table = line.match(/^\s*\[([^\][\r\n]+)]\s*(?:#.*)?(?:\r?\n)?$/); + const arrayTable = line.match(/^\s*\[\[([^\][\r\n]+)]]\s*(?:#.*)?(?:\r?\n)?$/); + if (table || arrayTable) { + if (found) { + found.end = offset; + return found; + } + if (table?.[1]?.trim() === sectionName) { + found = { + bodyStart: offset + line.length, + end: source.length, + }; + } + } + offset += line.length; + } + return found; +} + +function tomlCommentSuffix(line: string): string { + let quote: '"' | "'" | undefined; + for (let index = 0; index < line.length; index += 1) { + const char = line[index]; + if (quote === '"' && char === '\\') { + index += 1; + continue; + } + if (quote) { + if (char === quote) quote = undefined; + continue; + } + if (char === '"' || char === "'") { + quote = char; + continue; + } + if (char === '#') { + let start = index; + while (start > 0 && /[ \t]/.test(line[start - 1]!)) start -= 1; + return line.slice(start); + } + } + return ''; +} + +function replaceTomlAssignments( + body: string, + entries: Record, +): string { + let updated = body; + for (const [key, value] of Object.entries(entries)) { + if (!TOML_KEY_PATTERN.test(key)) { + throw new CLIError(`Invalid TOML key: ${key}`, ExitCode.GENERAL); + } + const rendered = typeof value === 'string' ? JSON.stringify(value) : String(value); + const line = `${key} = ${rendered}`; + const assignment = new RegExp(`^(\\s*)${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*=.*$`, 'm'); + if (assignment.test(updated)) { + updated = updated.replace( + assignment, + (match, indent: string) => `${indent}${line}${tomlCommentSuffix(match)}`, + ); + } else { + if (updated.length > 0 && !updated.endsWith('\n')) updated += '\n'; + updated += `${line}\n`; + } + } + return updated; +} + +function upsertTomlSection( + source: string, + sectionName: string, + entries: Record, +): string { + const section = findTomlSection(source, sectionName); + if (section) { + const body = source.slice(section.bodyStart, section.end); + return source.slice(0, section.bodyStart) + + replaceTomlAssignments(body, entries) + + source.slice(section.end); + } + + let updated = source; + if (updated.length > 0 && !updated.endsWith('\n')) updated += '\n'; + if (updated.length > 0 && !updated.endsWith('\n\n')) updated += '\n'; + updated += `[${sectionName}]\n${replaceTomlAssignments('', entries)}`; + return updated; +} + +function upsertTomlRoot( + source: string, + entries: Record, +): string { + const firstSection = source.search(/^\s*\[/m); + const rootEnd = firstSection === -1 ? source.length : firstSection; + const root = replaceTomlAssignments(source.slice(0, rootEnd), entries); + return root + source.slice(rootEnd); +} + +function updateToml( + before: string | null, + label: string, + root: Record, + sections: Array<{ name: string; entries: Record }>, +): string { + let source = before ?? ''; + try { + parseToml(source); + } catch { + throw new CLIError( + `${label} is not valid TOML.`, + ExitCode.GENERAL, + INVALID_CONFIG_HINT, + ); + } + + if (source.includes('"""') || source.includes("'''")) { + throw new CLIError( + `${label} contains a multiline string that mmx cannot safely preserve.`, + ExitCode.GENERAL, + 'Update this configuration manually. No files were changed.', + ); + } + + source = upsertTomlRoot(source, root); + for (const section of sections) { + source = upsertTomlSection(source, section.name, section.entries); + } + if (!source.endsWith('\n')) source += '\n'; + + try { + parseToml(source); + } catch { + throw new CLIError( + `Could not safely merge ${label}.`, + ExitCode.GENERAL, + 'No files were changed. Convert inline TOML tables to standard table sections and retry.', + ); + } + return source; +} + +function updateHermesYaml( + before: string | null, + provider: 'minimax' | 'minimax-cn', + model: MiniMaxModelId, + baseUrl: string, +): string { + const document = parseDocument(before ?? '{}\n'); + if (document.errors.length > 0) { + throw new CLIError( + `Hermes config.yaml is not valid YAML: ${document.errors[0]?.message ?? 'parse error'}`, + ExitCode.GENERAL, + INVALID_CONFIG_HINT, + ); + } + const root = document.toJS() as unknown; + assertObjectRoot(root, 'Hermes config.yaml'); + if (typeof root.model === 'string') { + document.deleteIn(['model']); + } else if (root.model !== undefined) { + assertObject( + root.model, + 'Hermes config.yaml model section must be an object.', + ); + } + if (root.providers !== undefined) { + assertObject( + root.providers, + 'Hermes config.yaml providers section must be an object.', + ); + } + const providerConfig = (root.providers as Record | undefined)?.[provider]; + if (providerConfig !== undefined) { + assertObject( + providerConfig, + `Hermes config.yaml providers.${provider} section must be an object.`, + ); + } + const configuredModels = (providerConfig as Record | undefined)?.models; + if (configuredModels !== undefined + && !Array.isArray(configuredModels) + && (typeof configuredModels !== 'object' || configuredModels === null)) { + throw new CLIError( + `Hermes config.yaml providers.${provider}.models must be an object or array.`, + ExitCode.GENERAL, + INVALID_CONFIG_HINT, + ); + } + if (Array.isArray(configuredModels)) { + const configuredIds = new Set(configuredModels.flatMap((entry) => { + if (typeof entry === 'string') return [entry]; + if (typeof entry === 'object' && entry !== null && !Array.isArray(entry)) { + const id = (entry as Record).id; + return typeof id === 'string' ? [id] : []; + } + return []; + })); + for (const candidate of MINIMAX_MODELS) { + if (!configuredIds.has(candidate.id)) { + document.addIn( + ['providers', provider, 'models'], + { id: candidate.id, context_length: candidate.contextWindow }, + ); + } + } + } else { + const modelMap = configuredModels as Record | undefined; + for (const candidate of MINIMAX_MODELS) { + if (modelMap?.[candidate.id] !== undefined) { + assertObject( + modelMap[candidate.id], + `Hermes model definition for ${candidate.id} must be an object.`, + ); + } + document.setIn( + ['providers', provider, 'models', candidate.id, 'context_length'], + candidate.contextWindow, + ); + } + } + const selectedModel = minimaxModel(model); + document.setIn(['model', 'default'], model); + document.setIn(['model', 'provider'], provider); + document.setIn(['model', 'base_url'], baseUrl); + document.setIn(['model', 'context_length'], selectedModel.contextWindow); + document.setIn(['model', 'max_tokens'], selectedModel.maxTokens); + return document.toString(); +} + +function dotenvValue(value: string): string { + return /^[A-Za-z0-9_./:-]+$/.test(value) + ? value + : JSON.stringify(value); +} + +function updateDotenv(before: string | null, key: string, value: string): string { + const source = before ?? ''; + const line = `${key}=${dotenvValue(value)}`; + const pattern = new RegExp(`^(?:export\\s+)?${key}=.*$`, 'gm'); + let found = false; + let updated = source.replace(pattern, () => { + if (found) return ''; + found = true; + return line; + }); + if (!found) { + if (updated.length > 0 && !updated.endsWith('\n')) updated += '\n'; + updated += `${line}\n`; + } + return updated.endsWith('\n') ? updated : `${updated}\n`; +} + +function prepareClaude(options: AgentSetupOptions, path: string): PreparedAgentFile[] { + const prepared = readPrepared(path); + const parsed = parseJsoncObject(prepared.before ?? '{}', 'Claude Code settings.json'); + const existingPicker = parsed.modelPicker; + if (existingPicker !== undefined) { + assertObject( + existingPicker, + 'Claude Code settings.json modelPicker section must be an object.', + ); + } + const existingPickerOptions = existingPicker?.options; + if (existingPickerOptions !== undefined && !Array.isArray(existingPickerOptions)) { + throw new CLIError( + 'Claude Code settings.json modelPicker.options must be an array.', + ExitCode.GENERAL, + INVALID_CONFIG_HINT, + ); + } + const existingReplaceBuiltIns = existingPicker?.replaceBuiltInOptions; + if (existingReplaceBuiltIns !== undefined && typeof existingReplaceBuiltIns !== 'boolean') { + throw new CLIError( + 'Claude Code settings.json modelPicker.replaceBuiltInOptions must be a boolean.', + ExitCode.GENERAL, + INVALID_CONFIG_HINT, + ); + } + const endpoints = endpointsForRegion(options.region); + const model = claudeModelId(options.model); + const pickerOptions = MINIMAX_MODELS.map(candidate => ({ + model: claudeModelId(candidate.id), + label: candidate.id, + description: candidate.id.endsWith('-highspeed') + ? '204.8K context · faster inference' + : `${candidate.contextWindow === 1000000 ? '1M' : '204.8K'} context`, + })); + const managedPickerModels = new Set(MINIMAX_MODELS.flatMap(candidate => [ + candidate.id, + claudeModelId(candidate.id), + ])); + const pickerUpdates: Array<{ path: Array; value: unknown }> = []; + if (existingPickerOptions === undefined) { + pickerUpdates.push({ path: ['modelPicker', 'options'], value: pickerOptions }); + } else { + const managedIndexes = existingPickerOptions.flatMap((entry, index) => { + if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) return []; + const entryModel = (entry as Record).model; + return typeof entryModel === 'string' && managedPickerModels.has(entryModel) ? [index] : []; + }); + for (const index of managedIndexes.reverse()) { + pickerUpdates.push({ path: ['modelPicker', 'options', index], value: undefined }); + } + const firstNewIndex = existingPickerOptions.length - managedIndexes.length; + pickerOptions.forEach((value, index) => { + pickerUpdates.push({ path: ['modelPicker', 'options', firstNewIndex + index], value }); + }); + } + const env = { + ANTHROPIC_BASE_URL: endpoints.anthropic, + ANTHROPIC_AUTH_TOKEN: options.apiKey, + API_TIMEOUT_MS: '3000000', + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', + CLAUDE_CODE_AUTO_COMPACT_WINDOW: String(minimaxModel(options.model).contextWindow), + ANTHROPIC_MODEL: model, + ANTHROPIC_DEFAULT_SONNET_MODEL: claudeModelId('MiniMax-M2.7'), + ANTHROPIC_DEFAULT_OPUS_MODEL: claudeModelId('MiniMax-M3'), + ANTHROPIC_DEFAULT_HAIKU_MODEL: claudeModelId('MiniMax-M2.7-highspeed'), + }; + return [{ + agent: 'claude-code', + path, + ...prepared, + after: updateJsonc(prepared.before, 'Claude Code settings.json', [ + ...Object.entries(env).map(([key, value]) => ({ path: ['env', key], value })), + ...pickerUpdates, + { + path: ['modelPicker', 'replaceBuiltInOptions'], + value: existingReplaceBuiltIns ?? true, + }, + ]), + }]; +} + +function codexModelCatalog(): string { + return `${JSON.stringify({ + _managed_by: CODEX_MODEL_CATALOG_OWNER, + models: MINIMAX_MODELS.map((model, priority) => ({ + slug: model.id, + display_name: model.id, + description: 'MiniMax', + default_reasoning_level: 'high', + supported_reasoning_levels: [ + { effort: 'none', description: 'Think-Off' }, + { effort: 'high', description: 'Deep' }, + ], + shell_type: 'shell_command', + visibility: 'list', + supported_in_api: true, + priority, + base_instructions: `You are Codex, a coding agent based on ${model.id}. You and the user share the same workspace and collaborate to achieve the user's goals.`, + supports_reasoning_summaries: true, + default_reasoning_summary: 'none', + support_verbosity: false, + truncation_policy: { mode: 'bytes', limit: 10000 }, + supports_parallel_tool_calls: true, + experimental_supported_tools: [], + context_window: model.contextWindow, + max_context_window: model.contextWindow, + input_modalities: [...model.input], + })), + }, null, 2)}\n`; +} + +function prepareCodex(options: AgentSetupOptions, paths: string[]): PreparedAgentFile[] { + const [configPath, catalogPath] = paths; + if (!configPath || !catalogPath) { + throw new CLIError('Codex paths are incomplete.', ExitCode.GENERAL); + } + const config = readPrepared(configPath); + const endpoints = endpointsForRegion(options.region); + let configAfter = updateToml(config.before, 'Codex config.toml', { + model: options.model, + model_provider: 'minimax', + }, [{ + name: 'model_providers.minimax', + entries: { + name: 'MiniMax', + base_url: endpoints.openai, + experimental_bearer_token: options.apiKey, + wire_api: 'responses', + }, + }]); + const configuredCatalog = parseToml(configAfter).model_catalog_json; + if (configuredCatalog !== undefined && configuredCatalog !== CODEX_MODEL_CATALOG_FILENAME) { + throw new CLIError( + 'Codex config.toml already uses a user-managed model catalog.', + ExitCode.GENERAL, + 'Remove model_catalog_json to let mmx manage a MiniMax-only catalog, or configure Codex manually. No files were changed.', + ); + } + + const catalog = readPrepared(catalogPath); + if (catalog.before !== null) { + const root = parseJsoncObject(catalog.before, 'Codex mmx-model-catalog.json'); + if (root._managed_by !== CODEX_MODEL_CATALOG_OWNER) { + throw new CLIError( + `Codex ${CODEX_MODEL_CATALOG_FILENAME} is not managed by mmx.`, + ExitCode.GENERAL, + 'Rename or remove that file, then retry. No files were changed.', + ); + } + } + configAfter = upsertTomlRoot(configAfter, { + model_catalog_json: CODEX_MODEL_CATALOG_FILENAME, + }); + return [ + { + agent: 'codex', + path: configPath, + ...config, + after: configAfter, + }, + { + agent: 'codex', + path: catalogPath, + ...catalog, + after: codexModelCatalog(), + }, + ]; +} + +function prepareGrok(options: AgentSetupOptions, path: string): PreparedAgentFile[] { + const prepared = readPrepared(path); + const endpoints = endpointsForRegion(options.region); + const sections = MINIMAX_MODELS.map(model => ({ + name: `model.${grokModelProfile(model.id)}`, + entries: { + model: model.id, + base_url: endpoints.openai, + name: model.id, + api_key: options.apiKey, + api_backend: 'chat_completions', + context_window: model.contextWindow, + max_completion_tokens: model.maxTokens, + }, + })); + return [{ + agent: 'grok', + path, + ...prepared, + after: updateToml(prepared.before, 'Grok config.toml', {}, [ + { name: 'models', entries: { default: grokModelProfile(options.model) } }, + ...sections, + ]), + }]; +} + +function prepareOpenCode(options: AgentSetupOptions, path: string): PreparedAgentFile[] { + const prepared = readPrepared(path); + const parsed = parseJsoncObject(prepared.before ?? '{}', 'OpenCode opencode.json'); + const provider = parsed.provider; + if (provider !== undefined) { + assertObject( + provider, + 'OpenCode opencode.json provider section must be an object.', + ); + } + const minimax = provider?.minimax; + if (minimax !== undefined) { + assertObject( + minimax, + 'OpenCode opencode.json provider.minimax section must be an object.', + ); + } + for (const key of ['options', 'models'] as const) { + const value = minimax?.[key]; + if (value !== undefined) { + assertObject( + value, + `OpenCode opencode.json provider.minimax.${key} section must be an object.`, + ); + } + } + const configuredModels = minimax?.models as Record | undefined; + for (const model of MINIMAX_MODELS) { + const configuredModel = configuredModels?.[model.id]; + if (configuredModel !== undefined) { + assertObject( + configuredModel, + `OpenCode model definition for ${model.id} must be an object.`, + ); + } + const limit = (configuredModel as Record | undefined)?.limit; + if (limit !== undefined) { + assertObject( + limit, + `OpenCode model definition for ${model.id} limit must be an object.`, + ); + } + const modalities = (configuredModel as Record | undefined)?.modalities; + if (modalities !== undefined) { + assertObject( + modalities, + `OpenCode model definition for ${model.id} modalities must be an object.`, + ); + } + } + const endpoints = endpointsForRegion(options.region); + const modelUpdates = MINIMAX_MODELS.flatMap((model) => { + const input: Array<'text' | 'image'> = [...model.input]; + return [ + { path: ['provider', 'minimax', 'models', model.id, 'name'], value: model.id }, + { + path: ['provider', 'minimax', 'models', model.id, 'attachment'], + value: input.includes('image'), + }, + { + path: ['provider', 'minimax', 'models', model.id, 'modalities', 'input'], + value: input, + }, + { + path: ['provider', 'minimax', 'models', model.id, 'modalities', 'output'], + value: ['text'], + }, + { + path: ['provider', 'minimax', 'models', model.id, 'limit', 'context'], + value: model.contextWindow, + }, + { + path: ['provider', 'minimax', 'models', model.id, 'limit', 'output'], + value: model.maxTokens, + }, + ]; + }); + return [{ + agent: 'opencode', + path, + ...prepared, + after: updateJsonc(prepared.before, 'OpenCode opencode.json', [ + { path: ['provider', 'minimax', 'npm'], value: '@ai-sdk/openai-compatible' }, + { path: ['provider', 'minimax', 'name'], value: 'MiniMax' }, + { path: ['provider', 'minimax', 'options', 'baseURL'], value: endpoints.openai }, + { path: ['provider', 'minimax', 'options', 'apiKey'], value: options.apiKey }, + { path: ['provider', 'minimax', 'options', 'setCacheKey'], value: true }, + ...modelUpdates, + { path: ['model'], value: `minimax/${options.model}` }, + ]), + }]; +} + +function prepareHermes(options: AgentSetupOptions, paths: string[]): PreparedAgentFile[] { + const [yamlPath, envPath] = paths; + if (!yamlPath || !envPath) throw new CLIError('Hermes paths are incomplete.', ExitCode.GENERAL); + const yaml = readPrepared(yamlPath); + const dotenv = readPrepared(envPath); + const endpoints = endpointsForRegion(options.region); + const provider = options.region === 'cn' ? 'minimax-cn' : 'minimax'; + const apiKeyName = options.region === 'cn' ? 'MINIMAX_CN_API_KEY' : 'MINIMAX_API_KEY'; + return [ + { + agent: 'hermes', + path: yamlPath, + ...yaml, + after: updateHermesYaml(yaml.before, provider, options.model, endpoints.anthropic), + }, + { + agent: 'hermes', + path: envPath, + ...dotenv, + after: updateDotenv(dotenv.before, apiKeyName, options.apiKey), + }, + ]; +} + +function preparePi(options: AgentSetupOptions, paths: string[]): PreparedAgentFile[] { + const [modelsPath, settingsPath] = paths; + if (!modelsPath || !settingsPath) throw new CLIError('Pi paths are incomplete.', ExitCode.GENERAL); + const models = readPrepared(modelsPath); + const settings = readPrepared(settingsPath); + const parsed = parseJsoncObject(models.before ?? '{}', 'Pi models.json'); + const endpoints = endpointsForRegion(options.region); + const providerId = options.region === 'cn' ? 'minimax-cn' : 'minimax'; + const providers = parsed.providers; + if (providers !== undefined) { + assertObject( + providers, + 'Pi models.json providers section must be an object.', + ); + } + const provider = providers?.[providerId]; + if (provider !== undefined) { + assertObject( + provider, + `Pi models.json providers.${providerId} section must be an object.`, + ); + } + const existingModels = provider?.models; + if (existingModels !== undefined && !Array.isArray(existingModels)) { + throw new CLIError( + `Pi models.json providers.${providerId}.models must be an array.`, + ExitCode.GENERAL, + INVALID_CONFIG_HINT, + ); + } + const providerUpdates: Array<{ path: Array; value: unknown }> = [ + { path: ['providers', providerId, 'name'], value: 'MiniMax' }, + { path: ['providers', providerId, 'baseUrl'], value: endpoints.anthropic }, + { path: ['providers', providerId, 'apiKey'], value: options.apiKey }, + { path: ['providers', providerId, 'api'], value: 'anthropic-messages' }, + ]; + const modelDefinitions = MINIMAX_MODELS.map(model => ({ + id: model.id, + name: model.id, + reasoning: true, + input: [...model.input], + contextWindow: model.contextWindow, + maxTokens: model.maxTokens, + })); + if (existingModels === undefined) { + providerUpdates.push({ + path: ['providers', providerId, 'models'], + value: modelDefinitions, + }); + } else { + let appendIndex = existingModels.length; + for (const definition of modelDefinitions) { + const modelIndex = existingModels.findIndex( + entry => typeof entry === 'object' + && entry !== null + && !Array.isArray(entry) + && (entry as Record).id === definition.id, + ); + const modelPath: Array = [ + 'providers', + providerId, + 'models', + modelIndex >= 0 ? modelIndex : appendIndex++, + ]; + if (modelIndex < 0) { + providerUpdates.push({ path: modelPath, value: definition }); + continue; + } + assertObject( + existingModels[modelIndex], + `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 }); + } + } + } + return [ + { + agent: 'pi', + path: modelsPath, + ...models, + after: updateJsonc(models.before, 'Pi models.json', providerUpdates), + }, + { + agent: 'pi', + path: settingsPath, + ...settings, + after: updateJsonc(settings.before, 'Pi settings.json', [ + { path: ['defaultProvider'], value: providerId }, + { path: ['defaultModel'], value: options.model }, + ]), + }, + ]; +} + +export function prepareAgentConfigurations(options: AgentSetupOptions): PreparedAgentFile[] { + const prepared: PreparedAgentFile[] = []; + for (const agent of options.agents) { + const paths = configPathsForAgent(agent, options); + switch (agent) { + case 'claude-code': + prepared.push(...prepareClaude(options, paths[0]!)); + break; + case 'codex': + prepared.push(...prepareCodex(options, paths)); + break; + case 'grok': + prepared.push(...prepareGrok(options, paths[0]!)); + break; + case 'opencode': + prepared.push(...prepareOpenCode(options, paths[0]!)); + break; + case 'hermes': + prepared.push(...prepareHermes(options, paths)); + break; + case 'pi': + prepared.push(...preparePi(options, paths)); + break; + } + } + return prepared; +} + +function atomicWritePrivate(path: string, contents: string, logicalPath: string): void { + const parent = dirname(path); + mkdirSync(parent, { recursive: true, mode: 0o700 }); + const temporary = join(parent, `.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`); + const assertTarget = () => { + if (writeTarget(logicalPath) !== path) { + throw new CLIError( + `Configuration path changed while mmx was writing it: ${logicalPath}`, + ExitCode.GENERAL, + ); + } + }; + try { + assertTarget(); + writeFileSync(temporary, contents, { encoding: 'utf8', mode: 0o600, flag: 'wx' }); + assertTarget(); + renameSync(temporary, path); + } catch (error) { + rmSync(temporary, { force: true }); + throw error; + } +} + +function createBackup(path: string, contents: string, timestamp: string): string { + for (let suffix = 0; ; suffix += 1) { + const backup = `${path}.bak.${timestamp}${suffix === 0 ? '' : `.${suffix}`}`; + try { + writeFileSync(backup, contents, { encoding: 'utf8', mode: 0o600, flag: 'wx' }); + return backup; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') continue; + rmSync(backup, { force: true }); + throw error; + } + } +} + +function backupTimestamp(): string { + return new Date().toISOString().replace(/[-:]/g, '').replace('T', '-').replace(/\.\d{3}Z$/, 'Z'); +} + +function writeTarget(path: string): string { + let candidate = path; + const missing: string[] = []; + while (true) { + try { + return join(realpathSync(candidate), ...missing); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + try { + if (lstatSync(candidate).isSymbolicLink()) { + throw new CLIError( + `Configuration path contains a dangling symbolic link: ${path}`, + ExitCode.GENERAL, + 'Fix the symbolic link and retry. No files were changed.', + ); + } + } catch (linkError) { + if (linkError instanceof CLIError) throw linkError; + if ((linkError as NodeJS.ErrnoException).code !== 'ENOENT') throw linkError; + } + const parent = dirname(candidate); + if (parent === candidate) return resolve(path); + missing.unshift(basename(candidate)); + candidate = parent; + } + } +} + +function hasWeakPermissions(mode: number): boolean { + return process.platform !== 'win32' && (mode & 0o077) !== 0; +} + +function isProcessRunning(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM'; + } +} + +function acquireAgentSetupLock(): () => void { + const owner = typeof process.getuid === 'function' + ? String(process.getuid()) + : (process.env.USERNAME ?? process.env.USER ?? 'user').replace(/[^A-Za-z0-9_-]/g, '_'); + const lockPath = join(tmpdir(), `mmx-agent-setup-${owner}.lock`); + const token = `${process.pid}:${Date.now()}:${Math.random().toString(16).slice(2)}`; + + try { + const descriptor = openSync(lockPath, 'wx', 0o600); + try { + writeFileSync(descriptor, `${token}\n`, 'utf8'); + } catch (error) { + closeSync(descriptor); + if (readExisting(lockPath)?.trim() === token) rmSync(lockPath, { force: true }); + throw error; + } + return () => { + closeSync(descriptor); + if (readExisting(lockPath)?.trim() === token) rmSync(lockPath, { force: true }); + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + + const rawOwner = readExisting(lockPath)?.trim() ?? ''; + const pidMatch = rawOwner.match(/^(\d+):/); + const pid = pidMatch?.[1] ? Number(pidMatch[1]) : undefined; + const stale = pid !== undefined && !isProcessRunning(pid); + throw new CLIError( + stale ? 'A stale mmx agent setup lock exists.' : 'Another mmx agent setup is already running.', + ExitCode.GENERAL, + stale + ? `Confirm no setup process is running, remove ${lockPath}, then retry. No files were changed.` + : 'Wait for it to finish, then retry. No files were changed.', + ); + } +} + +export function applyAgentConfigurations( + prepared: PreparedAgentFile[], + dryRun = false, +): AppliedAgentFile[] { + const releaseLock = dryRun ? 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); + } + const originalModes = new Map(); + const permissionOnly = new Map(); + for (const file of prepared) { + if (file.before !== null && existsSync(file.targetPath)) { + const mode = statSync(file.targetPath).mode & 0o777; + originalModes.set(file, mode); + if (file.before === file.after && hasWeakPermissions(mode)) { + permissionOnly.set(file, mode); + } + } + } + if (dryRun) { + return prepared.map((file) => ({ + agent: file.agent, + path: file.path, + status: file.before === file.after && !permissionOnly.has(file) + ? 'unchanged' + : 'would-configure', + })); + } + + const assertStillCurrent = (file: PreparedAgentFile, hint: string): void => { + if (writeTarget(file.path) !== file.targetPath + || readExisting(file.targetPath) !== file.before) { + throw new CLIError( + `Configuration changed while mmx was preparing it: ${file.path}`, + ExitCode.GENERAL, + hint, + ); + } + }; + for (const file of prepared) { + if (file.before !== file.after || permissionOnly.has(file)) { + assertStillCurrent(file, 'No files were changed. Retry the command.'); + } + } + + const timestamp = backupTimestamp(); + const backups = new Map(); + const written: Array<{ file: PreparedAgentFile; target: string }> = []; + try { + for (const file of changed) { + if (file.before !== null) { + assertStillCurrent(file, 'No files were changed. Retry the command.'); + backups.set(file.path, createBackup(file.targetPath, file.before, timestamp)); + } + } + for (const file of prepared) { + if (!permissionOnly.has(file)) continue; + assertStillCurrent(file, 'The completed writes were rolled back. Retry the command.'); + chmodSync(file.targetPath, 0o600); + } + for (const file of changed) { + assertStillCurrent(file, 'The completed writes were rolled back. Retry the command.'); + atomicWritePrivate(file.targetPath, file.after, file.path); + written.push({ file, target: file.targetPath }); + } + } catch (error) { + const conflicts: string[] = []; + let rollbackError: unknown; + for (const { file, target } of written.reverse()) { + try { + if (readExisting(target) !== file.after) { + conflicts.push(file.path); + continue; + } + if (file.before === null) { + rmSync(target, { force: true }); + } else { + atomicWritePrivate(target, file.before, file.path); + const mode = originalModes.get(file); + if (mode !== undefined) chmodSync(target, mode); + } + } catch (candidate) { + rollbackError ??= candidate; + } + } + for (const [file, mode] of permissionOnly) { + try { + chmodSync(file.targetPath, mode); + } catch (candidate) { + rollbackError ??= candidate; + } + } + if (conflicts.length === 0 && rollbackError === undefined) { + for (const backup of backups.values()) { + try { + rmSync(backup, { force: true }); + } catch (candidate) { + rollbackError ??= candidate; + } + } + } + if (conflicts.length > 0 || rollbackError !== undefined) { + throw new CLIError( + 'Could not fully roll back agent configuration.', + ExitCode.GENERAL, + `Review the affected files${backups.size > 0 + ? ` and backups: ${[...backups.values()].join(', ')}` + : '.'}`, + ); + } + throw error; + } + + return prepared.map((file) => ({ + agent: file.agent, + path: file.path, + status: file.before === file.after && !permissionOnly.has(file) + ? 'unchanged' + : 'configured', + ...(backups.has(file.path) ? { backup: backups.get(file.path) } : {}), + })); + } finally { + releaseLock?.(); + } +} diff --git a/src/agent/types.ts b/src/agent/types.ts new file mode 100644 index 0000000..7d7c68f --- /dev/null +++ b/src/agent/types.ts @@ -0,0 +1,67 @@ +import type { Region } from '../config/schema'; + +export const AGENT_IDS = [ + 'claude-code', + 'codex', + 'grok', + 'opencode', + 'hermes', + 'pi', +] as const; + +export type AgentId = typeof AGENT_IDS[number]; + +export const MINIMAX_MODELS = [ + { + id: 'MiniMax-M3', + contextWindow: 1000000, + maxTokens: 128000, + input: ['text', 'image'], + }, + { + id: 'MiniMax-M2.7', + contextWindow: 204800, + maxTokens: 131072, + input: ['text'], + }, + { + id: 'MiniMax-M2.7-highspeed', + contextWindow: 204800, + maxTokens: 131072, + input: ['text'], + }, +] as const; + +export const DEFAULT_MINIMAX_MODEL = MINIMAX_MODELS[0].id; +export type MiniMaxModelId = typeof MINIMAX_MODELS[number]['id']; + +export interface AgentSetupOptions { + agents: AgentId[]; + apiKey: string; + region: Region; + model: MiniMaxModelId; + homeDir?: string; + env?: NodeJS.ProcessEnv; +} + +export interface PreparedAgentFile { + agent: AgentId; + path: string; + targetPath: string; + before: string | null; + after: string; +} + +export interface AppliedAgentFile { + agent: AgentId; + path: string; + status: 'configured' | 'unchanged' | 'would-configure'; + backup?: string; +} + +export interface AgentVerification { + region: Region; + model: string; + endpoint: string; + status: 'ok' | 'skipped'; +} diff --git a/src/agent/verify.ts b/src/agent/verify.ts new file mode 100644 index 0000000..69c185c --- /dev/null +++ b/src/agent/verify.ts @@ -0,0 +1,94 @@ +import { CLIError } from '../errors/base'; +import { ExitCode } from '../errors/codes'; +import { parseSSE } from '../client/stream'; +import { endpointsForRegion } from './configurator'; +import type { AgentVerification } from './types'; +import type { Region } from '../config/schema'; + +export async function verifyAgentCredential(options: { + apiKey: string; + region: Region; + model: string; + timeoutSeconds?: number; +}): Promise { + const endpoint = `${endpointsForRegion(options.region).openai}/responses`; + let response: Response; + try { + response = await fetch(endpoint, { + method: 'POST', + headers: { + Authorization: `Bearer ${options.apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: options.model, + input: 'Reply with exactly OK.', + max_output_tokens: 16, + stream: true, + }), + signal: AbortSignal.timeout((options.timeoutSeconds ?? 30) * 1000), + }); + } 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.', + ); + } + + 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.', + ); + } + + let verified = false; + try { + for await (const event of parseSSE(response)) { + let payload: unknown; + try { + payload = JSON.parse(event.data) as unknown; + } catch { + continue; + } + if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) continue; + const record = payload as Record; + const created = record.response; + if (record.type === 'response.created' + && typeof created === 'object' + && created !== null + && !Array.isArray(created) + && typeof (created as Record).id === 'string' + && typeof (created as Record).model === 'string') { + verified = true; + break; + } + } + } catch { + throw new CLIError( + 'Could not read the MiniMax agent verification response.', + ExitCode.NETWORK, + 'No agent configuration files were changed.', + ); + } finally { + await response.body?.cancel().catch(() => undefined); + } + + if (!verified) { + throw new CLIError( + 'MiniMax returned an invalid agent verification response.', + ExitCode.GENERAL, + 'No agent configuration files were changed. Check --region, --model, and the API key.', + ); + } + + return { + region: options.region, + model: options.model, + endpoint, + status: 'ok', + }; +} diff --git a/src/args.ts b/src/args.ts index 0fe2f34..686ad03 100644 --- a/src/args.ts +++ b/src/args.ts @@ -26,20 +26,57 @@ interface FlagSchema { booleans: Set; numbers: Set; arrays: Set; + keys: Set; } function buildSchema(options: OptionDef[]): FlagSchema { const booleans = new Set(); const numbers = new Set(); const arrays = new Set(); + const keys = new Set(); for (const opt of options) { const key = flagKey(opt); if (!key) continue; + keys.add(key); if (isBooleanDef(opt)) booleans.add(key); else if (opt.type === 'number') numbers.add(key); else if (opt.type === 'array') arrays.add(key); } - return { booleans, numbers, arrays }; + return { booleans, numbers, arrays, keys }; +} + +/** Validate option names and values without changing parsing for existing commands. */ +export function findOptionError(argv: string[], options: OptionDef[]): string | undefined { + const schema = buildSchema(options); + let i = 0; + while (i < argv.length) { + const arg = argv[i]!; + if (arg === '--') break; + if (arg.startsWith('--')) { + const eqIdx = arg.indexOf('='); + const rawKey = eqIdx === -1 ? arg.slice(2) : arg.slice(2, eqIdx); + const key = kebabToCamel(rawKey); + if (!schema.keys.has(key)) return `Unknown option: --${rawKey}`; + if (eqIdx !== -1 && !schema.booleans.has(key) && arg.slice(eqIdx + 1).trim() === '') { + return `Option --${rawKey} requires a value.`; + } + if (eqIdx === -1 && !schema.booleans.has(key)) { + const value = argv[i + 1]; + if (value === undefined || value.startsWith('-')) { + return `Option --${rawKey} requires a value.`; + } + i += 2; + continue; + } + i += 1; + continue; + } + if (arg.startsWith('-') && arg !== '-h' && arg !== '-v') { + return `Unknown option: ${arg}`; + } + i += 1; + } + return undefined; } /** diff --git a/src/commands/agent/setup.ts b/src/commands/agent/setup.ts new file mode 100644 index 0000000..ef2ffa8 --- /dev/null +++ b/src/commands/agent/setup.ts @@ -0,0 +1,335 @@ +import colors from 'picocolors'; + +import { defineCommand } from '../../command'; +import { + applyAgentConfigurations, + prepareAgentConfigurations, +} from '../../agent/configurator'; +import { detectAgentsOnPath } from '../../agent/availability'; +import { + AGENT_IDS, + DEFAULT_MINIMAX_MODEL, + MINIMAX_MODELS, + type AgentId, + type AgentSetupOptions, + type AgentVerification, + type AppliedAgentFile, +} from '../../agent/types'; +import { verifyAgentCredential } from '../../agent/verify'; +import { CLIError } from '../../errors/base'; +import { ExitCode } from '../../errors/codes'; +import { formatOutput, detectOutputFormat } from '../../output/formatter'; +import { + promptConfirm, + promptApiKey, + promptMultiSelect, + promptNote, + promptSelect, + withPromptSpinner, +} from '../../utils/prompt'; +import { DOCS_HOSTS, type Config } from '../../config/schema'; +import type { GlobalFlags } from '../../types/flags'; + +const AGENT_ALIASES: Record = { + claude: 'claude-code', + claudecode: 'claude-code', + 'claude-code': 'claude-code', + codex: 'codex', + grok: 'grok', + 'grok-build': 'grok', + 'grok-cli': 'grok', + opencode: 'opencode', + hermes: 'hermes', + pi: 'pi', +}; + +const AGENT_LABELS: Record = { + 'claude-code': 'Claude Code', + codex: 'Codex', + grok: 'Grok CLI (Grok Build)', + opencode: 'OpenCode', + hermes: 'Hermes Agent', + pi: 'Pi', +}; +const DEFAULT_INTERACTIVE_AGENTS: AgentId[] = ['claude-code', 'codex']; + +type ApiKeyKind = 'token-plan' | 'paygo'; + +const API_KEY_CHOICES: Array<{ + value: ApiKeyKind; + label: string; + hint: string; + pagePath: string; + noteTitle: string; + prompt: string; +}> = [ + { + value: 'token-plan', + label: 'Token Plan (sk-cp-...)', + hint: 'Uses plan quota and Credits', + pagePath: '/user-center/payment/token-plan', + noteTitle: 'Get your Token Plan key', + prompt: 'Paste your Token Plan key', + }, + { + value: 'paygo', + label: 'Pay-as-you-go (sk-api-...)', + hint: 'Uses account balance', + pagePath: '/user-center/basic-information/interface-key', + noteTitle: 'Create a pay-as-you-go key', + prompt: 'Paste your pay-as-you-go key', + }, +]; + +function detectApiKeyKind(apiKey: string): ApiKeyKind | undefined { + if (apiKey.startsWith('sk-cp-')) return 'token-plan'; + if (apiKey.startsWith('sk-api-')) return 'paygo'; + return undefined; +} + +function formatAgentSetupResult( + result: { + verification: AgentVerification; + agents: AgentId[]; + files: AppliedAgentFile[]; + }, + format: 'text' | 'json', + color: boolean, +): string { + if (format === 'json' || !color) return formatOutput(result, format); + + const key = colors.cyan; + const value = colors.green; + return formatOutput({ + [key('verification')]: { + [key('region')]: result.verification.region, + [key('model')]: result.verification.model, + [key('endpoint')]: result.verification.endpoint, + [key('status')]: value(result.verification.status), + }, + [key('agents')]: result.agents.map((agent) => value(agent)), + [key('files')]: result.files.map((file) => ({ + [key('agent')]: value(file.agent), + [key('path')]: file.path, + [key('status')]: value(file.status), + ...(file.backup ? { [key('backup')]: file.backup } : {}), + })), + }, 'text'); +} + +function uniqueAgents(values: string[]): AgentId[] { + const result: AgentId[] = []; + for (const raw of values) { + const normalized = raw.trim().toLowerCase(); + const agent = AGENT_ALIASES[normalized]; + if (!agent) { + throw new CLIError( + `Unsupported agent "${raw}".`, + ExitCode.USAGE, + `Supported agents: ${AGENT_IDS.join(', ')}`, + ); + } + if (!result.includes(agent)) result.push(agent); + } + return result; +} + +function isInteractiveInvocation(flags: GlobalFlags): boolean { + if (flags._hasExplicitOptions === true) return false; + return Object.values(flags).every((value) => value === undefined || value === false); +} + +async function interactiveOptions( + config: Config, + detectedAgents: Set, +): Promise { + const selectedAgents = await promptMultiSelect({ + message: 'Select agents to configure', + choices: AGENT_IDS.map((agent) => ({ + value: agent, + label: detectedAgents.has(agent) + ? AGENT_LABELS[agent] + : `${AGENT_LABELS[agent]} (not detected on PATH)`, + })), + initialValues: DEFAULT_INTERACTIVE_AGENTS.filter((agent) => detectedAgents.has(agent)), + required: true, + }); + if (!selectedAgents?.length) { + throw new CLIError('Agent setup cancelled.', ExitCode.GENERAL); + } + const agents = uniqueAgents(selectedAgents); + + const selectedRegion = await promptSelect({ + message: 'Select your MiniMax service region', + choices: [ + { value: 'global', label: 'Global (minimax.io)' }, + { value: 'cn', label: 'Mainland China (minimaxi.com)' }, + ], + initialValue: config.region, + }); + if (selectedRegion !== 'global' && selectedRegion !== 'cn') { + throw new CLIError('Agent setup cancelled.', ExitCode.GENERAL); + } + const selectedKeyKind = await promptSelect({ + message: 'Choose an API key type', + choices: API_KEY_CHOICES, + initialValue: 'token-plan', + }); + const keyChoice = API_KEY_CHOICES.find(choice => choice.value === selectedKeyKind); + if (!keyChoice) throw new CLIError('Agent setup cancelled.', ExitCode.GENERAL); + + await promptNote({ + title: keyChoice.noteTitle, + message: `${DOCS_HOSTS[selectedRegion]}${keyChoice.pagePath}\n\nCopy the key, then paste it below.`, + }); + const apiKey = (await promptApiKey({ + message: keyChoice.prompt, + }))?.trim(); + + const detectedKind = apiKey ? detectApiKeyKind(apiKey) : undefined; + if (detectedKind && detectedKind !== keyChoice.value) { + const detectedChoice = API_KEY_CHOICES.find(choice => choice.value === detectedKind); + if (detectedChoice) { + await promptNote({ + title: 'API key type detected', + message: `This looks like ${detectedChoice.label}. ${detectedChoice.hint}.`, + }); + } + } + if (!apiKey) { + 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.'; + } + const confirmed = await promptConfirm({ message }); + if (!confirmed) throw new CLIError('Agent setup cancelled.', ExitCode.GENERAL); + + return { agents, apiKey, region: selectedRegion, model: DEFAULT_MINIMAX_MODEL }; +} + +function nonInteractiveOptions(flags: GlobalFlags): AgentSetupOptions { + const positional = flags._positional as string[] | undefined; + if (positional?.length) { + throw new CLIError( + 'Unexpected positional argument.', + ExitCode.USAGE, + 'Select agents with --agent or --all.', + ); + } + const requested = uniqueAgents((flags.agent as string[] | undefined) ?? []); + const selected = flags.all ? [...AGENT_IDS] : requested; + if (selected.length === 0) { + throw new CLIError( + 'At least one --agent or --all is required in non-interactive mode.', + ExitCode.USAGE, + 'mmx agent setup --agent codex --api-key --region global', + ); + } + + if (flags.region !== 'global' && flags.region !== 'cn') { + throw new CLIError( + '--region global|cn is required in non-interactive mode.', + ExitCode.USAGE, + 'Supplying the region makes scripts deterministic and prevents writing configs for the wrong service.', + ); + } + + const apiKey = (flags.apiKey as string | undefined)?.trim(); + if (!apiKey) { + throw new CLIError( + 'A MiniMax API key is required in non-interactive mode.', + ExitCode.USAGE, + 'Pass --api-key .\n' + + 'Token Plan keys (sk-cp-...) and pay-as-you-go keys (sk-api-...) use separate quotas.', + ); + } + + const model = ((flags.model as string | undefined) ?? DEFAULT_MINIMAX_MODEL).trim(); + if (!model) throw new CLIError('--model must not be empty.', ExitCode.USAGE); + const supportedModel = MINIMAX_MODELS.find(candidate => candidate.id === model)?.id; + if (!supportedModel) { + throw new CLIError( + `Unsupported MiniMax model "${model}".`, + ExitCode.USAGE, + `Supported models: ${MINIMAX_MODELS.map(candidate => candidate.id).join(', ')}`, + ); + } + return { agents: selected, 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)', + usage: 'mmx agent setup [--agent ... | --all] [--api-key ] [--region ]', + options: [ + { + flag: '--agent ', + description: 'Agent: claude-code, codex, grok/grok-build, opencode, hermes, pi (repeatable)', + type: 'array', + }, + { + flag: '--api-key ', + description: 'API key only; Token Plan (sk-cp) and pay-as-you-go (sk-api) keys are not interchangeable', + }, + { flag: '--all', description: 'Configure every supported agent' }, + { + flag: '--model ', + description: `Default model (${MINIMAX_MODELS.map(model => model.id).join(', ')})`, + }, + ], + examples: [ + 'mmx agent setup', + 'mmx agent setup --agent claude-code --agent codex --api-key --region global', + 'mmx agent setup --all --api-key --region cn --output json', + 'mmx agent setup --agent opencode --api-key --region cn --dry-run', + ], + async run(config: Config, flags: GlobalFlags) { + const detectedAgents = detectAgentsOnPath(); + const interactive = isInteractiveInvocation(flags); + const options = interactive + ? await interactiveOptions(config, detectedAgents) + : nonInteractiveOptions(flags); + + let verification: AgentVerification = { + region: options.region, + model: options.model, + endpoint: '', + status: 'skipped', + }; + if (!config.dryRun) { + const verify = () => verifyAgentCredential({ + apiKey: options.apiKey, + region: options.region, + model: options.model, + timeoutSeconds: Math.min(config.timeout, 60), + }); + verification = interactive ? await withPromptSpinner({ + message: 'Verifying API key with MiniMax...', + successMessage: 'API key verified.', + errorMessage: 'API key verification failed.', + }, verify) : await verify(); + } + + const prepared = prepareAgentConfigurations(options); + const files = applyAgentConfigurations(prepared, config.dryRun); + const format = detectOutputFormat(config.output); + console.log(formatAgentSetupResult({ + verification, + agents: options.agents, + files, + }, format, interactive && !config.noColor)); + const notDetected = options.agents.filter((agent) => !detectedAgents.has(agent)); + if (!interactive && notDetected.length > 0 && !config.quiet) { + process.stderr.write( + `Warning: Not detected on PATH: ${notDetected.map((agent) => AGENT_LABELS[agent]).join(', ')}. ` + + 'mmx can write configuration files for them, but will not download or install them for you.\n', + ); + } + }, +}); diff --git a/src/main.ts b/src/main.ts index 1791fdb..d2ee350 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,8 @@ -import { scanCommandPath, parseFlags } from './args'; +import { findOptionError, scanCommandPath, parseFlags } from './args'; import { registry } from './registry'; import { GLOBAL_OPTIONS } from './command'; +import { CLIError } from './errors/base'; +import { ExitCode } from './errors/codes'; import { handleError } from './errors/handler'; import { loadConfig, readConfigFile } from './config/loader'; import { detectRegion, saveDetectedRegion } from './config/detect-region'; @@ -41,7 +43,15 @@ async function main() { process.exit(0); } - const commandPath = scanCommandPath(argv, GLOBAL_OPTIONS); + let commandPath = scanCommandPath(argv, GLOBAL_OPTIONS); + if (commandPath[0] === 'agent') { + const { command: agentSetupCommand } = registry.resolve(['agent', 'setup']); + commandPath = scanCommandPath(argv, [ + ...GLOBAL_OPTIONS, + ...(agentSetupCommand.options ?? []), + ]); + if (commandPath.length === 1) commandPath.push('setup'); + } // Proxy: env vars take precedence over config file const rawConfig = readConfigFile(); @@ -82,20 +92,40 @@ async function main() { } const { command, extra } = registry.resolve(commandPath); - const flags = parseFlags(argv, [...GLOBAL_OPTIONS, ...(command.options ?? [])]); - - if (extra.length > 0) (flags as Record)._positional = extra; + const isAgentSetup = command.name === 'agent setup'; + const flagOptions = [...GLOBAL_OPTIONS, ...(command.options ?? [])]; + if (isAgentSetup) { + const optionError = findOptionError(argv, flagOptions); + if (optionError) { + throw new CLIError( + optionError, + ExitCode.USAGE, + 'Run mmx agent setup --help to see supported options.', + ); + } + } + const flags = parseFlags(argv, flagOptions); + + const separatorIndex = isAgentSetup ? argv.indexOf('--') : -1; + const positionals = separatorIndex >= 0 + ? [...extra, ...argv.slice(separatorIndex + 1)] + : extra; + if (positionals.length > 0) (flags as Record)._positional = positionals; + if (isAgentSetup) { + (flags as Record)._hasExplicitOptions = positionals.length > 0 + || argv.some((arg) => arg.startsWith('-')); + } const config = loadConfig(flags); - const needsAuthSetup = !NO_AUTH_SETUP.some( + const needsAuthSetup = !isAgentSetup && !NO_AUTH_SETUP.some( (cmd) => cmd.every((c, i) => commandPath[i] === c), ); if (needsAuthSetup) { await ensureAuth(config); } - if (config.needsRegionDetection) { + if (config.needsRegionDetection && !isAgentSetup) { const apiKey = config.apiKey || config.fileApiKey; if (apiKey) { const detected = await detectRegion(apiKey); @@ -106,7 +136,9 @@ async function main() { } } - const updateCheckPromise = checkForUpdate(CLI_VERSION).catch(() => {}); + const updateCheckPromise = isAgentSetup && config.dryRun + ? Promise.resolve() + : checkForUpdate(CLI_VERSION).catch(() => {}); await command.execute(config, flags); diff --git a/src/registry.ts b/src/registry.ts index f7eb8fc..25ae001 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -26,6 +26,7 @@ import fileList from './commands/file/list'; import fileDelete from './commands/file/delete'; import update from './commands/update'; import help from './commands/help'; +import agentSetup from './commands/agent/setup'; export type { Command, OptionDef } from './command'; @@ -218,6 +219,7 @@ ${b('Resources:')} ${a('vision')} ${d('Image understanding (describe)')} ${a('quota')} ${d('Usage quotas (show)')} ${a('config')} ${d('CLI configuration (show, set, export-schema)')} + ${a('agent')} ${d('External coding agent integration (setup)')} ${a('file')} ${d('File storage (upload, list, delete)')} ${a('update')} ${d('Update mmx to a newer version')} @@ -311,4 +313,5 @@ export const registry = new CommandRegistry({ 'file delete': fileDelete, 'update': update, 'help': help, + 'agent setup': agentSetup, }); diff --git a/src/utils/prompt.ts b/src/utils/prompt.ts index 0f70dc9..94e9dbd 100644 --- a/src/utils/prompt.ts +++ b/src/utils/prompt.ts @@ -10,9 +10,11 @@ * case explicitly. */ -import { isInteractive } from './env.js'; +import colors from 'picocolors'; + import { CLIError } from '../errors/base.js'; import { ExitCode } from '../errors/codes'; +import { isInteractive } from './env.js'; // Dynamic import to avoid loading @clack/prompts in non-interactive envs unnecessarily // (though for CLI tools the startup cost is usually acceptable) @@ -42,6 +44,65 @@ export async function promptText(options: { return val as string; } +const API_KEY_VISIBLE_LENGTH = 30; + +export function apiKeyPreview(value: string): string { + if (value.length <= API_KEY_VISIBLE_LENGTH) return value; + const visible = value.slice(0, API_KEY_VISIBLE_LENGTH); + return `${visible}.... (${value.length} chars)`; +} + +export async function promptApiKey(options: { message: string }): Promise { + if (!isInteractive()) return undefined; + + const { TextPrompt } = await import('@clack/core'); + const val = await new TextPrompt({ + render() { + const preview = apiKeyPreview(this.userInput); + const cursor = colors.inverse(colors.hidden('_')); + const leadingBar = colors.gray('│'); + switch (this.state) { + case 'submit': + return `${leadingBar}\n${colors.green('◇')} ${options.message}\n` + + `${leadingBar} ${colors.dim(preview)}`; + case 'cancel': + return `${leadingBar}\n${colors.red('■')} ${options.message}\n` + + `${leadingBar} ${colors.strikethrough(colors.dim(preview))}`; + case 'error': + return `${leadingBar}\n${colors.yellow('▲')} ${options.message}\n` + + `${colors.yellow('│')} ${preview}${cursor}\n` + + `${colors.yellow('└')} ${colors.yellow(this.error)}`; + default: + return `${leadingBar}\n${colors.cyan('◆')} ${options.message}\n` + + `${colors.cyan('│')} ${preview}${cursor}\n${colors.cyan('└')}`; + } + }, + }).prompt(); + + if (typeof val === 'symbol') return undefined; + return val; +} + +export async function withPromptSpinner(options: { + message: string; + successMessage: string; + errorMessage: string; +}, task: () => Promise): Promise { + if (!isInteractive()) return task(); + + const { spinner } = await import('@clack/prompts'); + const indicator = spinner(); + indicator.start(options.message); + try { + const result = await task(); + indicator.stop(options.successMessage); + return result; + } catch (error) { + indicator.error(options.errorMessage); + throw error; + } +} + /** * Like promptText but confirms with y/N before proceeding. */ @@ -59,6 +120,52 @@ export async function promptConfirm(options: { return val as boolean; } +export async function promptNote(options: { + message: string; + title?: string; +}): Promise { + if (!isInteractive()) return; + + const inquirer = await import('@clack/prompts'); + inquirer.note(options.message, options.title); +} + +export async function promptSelect(options: { + message: string; + choices: Array<{ value: string; label: string; hint?: string }>; + initialValue?: string; +}): Promise { + if (!isInteractive()) return undefined; + + const inquirer = await import('@clack/prompts'); + const val = await inquirer.select({ + message: options.message, + options: options.choices, + initialValue: options.initialValue, + }); + if (typeof val === 'symbol') return undefined; + return val as string; +} + +export async function promptMultiSelect(options: { + message: string; + choices: Array<{ value: string; label: string; hint?: string }>; + initialValues?: string[]; + required?: boolean; +}): Promise { + if (!isInteractive()) return undefined; + + const inquirer = await import('@clack/prompts'); + const val = await inquirer.multiselect({ + message: options.message, + options: options.choices, + initialValues: options.initialValues, + required: options.required, + }); + if (typeof val === 'symbol') return undefined; + return val as string[]; +} + /** * Fail fast with a user-friendly error when a required option is missing * in non-interactive (agent / CI) mode. diff --git a/test/agent/availability.test.ts b/test/agent/availability.test.ts new file mode 100644 index 0000000..44a2fe9 --- /dev/null +++ b/test/agent/availability.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it } from 'bun:test'; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { detectAgentsOnPath } from '../../src/agent/availability'; + +describe('agent availability', () => { + const roots: string[] = []; + + function executableDirectory(command: string): string { + const root = mkdtempSync(join(tmpdir(), 'mmx-agent-path-')); + roots.push(root); + const suffix = process.platform === 'win32' ? '.CMD' : ''; + const executable = join(root, `${command}${suffix}`); + writeFileSync(executable, ''); + chmodSync(executable, 0o700); + return root; + } + + afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); + }); + + it('detects supported executable names without treating directories as commands', () => { + const root = executableDirectory('pi'); + const suffix = process.platform === 'win32' ? '.CMD' : ''; + mkdirSync(join(root, `codex${suffix}`)); + + const detected = detectAgentsOnPath({ + PATH: root, + PATHEXT: '.COM;.EXE;.BAT;.CMD', + }); + + expect(detected).toEqual(new Set(['pi'])); + }); + + it('recognizes every supported Grok executable name', () => { + for (const command of ['grok', 'grok-build', 'grok-cli']) { + const root = executableDirectory(command); + expect(detectAgentsOnPath({ PATH: root, PATHEXT: '.COM;.EXE;.BAT;.CMD' }).has('grok')) + .toBe(true); + } + }); + + it('does not scan the working directory when PATH is missing', () => { + const root = executableDirectory('codex'); + const originalCwd = process.cwd(); + process.chdir(root); + try { + expect(detectAgentsOnPath({})).toEqual(new Set()); + } finally { + process.chdir(originalCwd); + } + }); + + it('does not treat Path as PATH on case-sensitive platforms', () => { + if (process.platform === 'win32') return; + const root = executableDirectory('pi'); + + expect(detectAgentsOnPath({ Path: root })).toEqual(new Set()); + }); +}); diff --git a/test/agent/configurator.test.ts b/test/agent/configurator.test.ts new file mode 100644 index 0000000..2098e15 --- /dev/null +++ b/test/agent/configurator.test.ts @@ -0,0 +1,474 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'fs'; +import { homedir, tmpdir } from 'os'; +import { join } from 'path'; + +import { parse as parseToml } from 'smol-toml'; +import { parse as parseYaml } from 'yaml'; + +import { + applyAgentConfigurations, + prepareAgentConfigurations, +} from '../../src/agent/configurator'; +import { AGENT_IDS, type AgentId, type AgentSetupOptions } from '../../src/agent/types'; + +describe('agent configurator', () => { + let home: string; + + function setupOptions( + agents: AgentId[], + overrides: Partial = {}, + ): AgentSetupOptions { + return { + agents, + apiKey: 'sk-test-secret', + region: 'global', + model: 'MiniMax-M3', + homeDir: home, + env: {}, + ...overrides, + }; + } + + beforeEach(() => { + home = join(tmpdir(), `mmx-agent-config-${process.pid}-${Date.now()}`); + mkdirSync(join(home, '.claude'), { recursive: true }); + mkdirSync(join(home, '.codex'), { recursive: true }); + mkdirSync(join(home, '.config', 'opencode'), { recursive: true }); + writeFileSync( + join(home, '.claude', 'settings.json'), + '{\n "theme": "dark",\n "modelPicker": {\n' + + ' "options": [\n' + + ' { "model": "keep-model", "label": "Keep" },\n' + + ' { "model": "MiniMax-M2.7", "label": "Old MiniMax" }\n' + + ' ],\n "replaceBuiltInOptions": false\n }\n}\n', + ); + writeFileSync(join(home, '.codex', 'config.toml'), '[mcp_servers.keep]\ncommand = "keep"\n'); + writeFileSync( + join(home, '.config', 'opencode', 'opencode.json'), + '{\n // keep this setting\n "theme": "system",\n "provider": {\n "minimax": {\n "extra": true,\n "options": { "headers": { "x-keep": "yes" } },\n "models": {\n "keep-model": { "name": "Keep" },\n "MiniMax-M3": { "custom": true }\n }\n }\n }\n}\n', + ); + mkdirSync(join(home, '.pi', 'agent'), { recursive: true }); + writeFileSync( + 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', + ); + }); + + afterEach(() => { + rmSync(home, { recursive: true, force: true }); + }); + + it('configures all supported agents without discarding unrelated settings', () => { + const prepared = prepareAgentConfigurations(setupOptions([...AGENT_IDS], { region: 'cn' })); + const result = applyAgentConfigurations(prepared); + + expect(result.every((file) => file.status === 'configured')).toBe(true); + + const claude = JSON.parse(readFileSync(join(home, '.claude', 'settings.json'), 'utf8')); + expect(claude.theme).toBe('dark'); + expect(claude.env.ANTHROPIC_BASE_URL).toBe('https://api.minimaxi.com/anthropic'); + expect(claude.env.ANTHROPIC_AUTH_TOKEN).toBe('sk-test-secret'); + expect(claude.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe('1000000'); + expect(claude.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS).toBeUndefined(); + expect(claude.env.ANTHROPIC_MODEL).toBe('MiniMax-M3[1m]'); + expect(claude.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('MiniMax-M3[1m]'); + expect(claude.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('MiniMax-M2.7'); + expect(claude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('MiniMax-M2.7-highspeed'); + expect(claude.modelPicker).toEqual({ + options: [ + { model: 'keep-model', label: 'Keep' }, + { model: 'MiniMax-M3[1m]', label: 'MiniMax-M3', description: '1M context' }, + { model: 'MiniMax-M2.7', label: 'MiniMax-M2.7', description: '204.8K context' }, + { + model: 'MiniMax-M2.7-highspeed', + label: 'MiniMax-M2.7-highspeed', + description: '204.8K context · faster inference', + }, + ], + replaceBuiltInOptions: false, + }); + + const codex = parseToml(readFileSync(join(home, '.codex', 'config.toml'), 'utf8')); + expect(codex.model).toBe('MiniMax-M3'); + expect(codex.model_provider).toBe('minimax'); + expect(codex.model_catalog_json).toBe('mmx-model-catalog.json'); + expect(codex.mcp_servers).toEqual({ keep: { command: 'keep' } }); + expect((codex.model_providers as Record>).minimax.base_url) + .toBe('https://api.minimaxi.com/v1'); + const codexCatalog = JSON.parse( + readFileSync(join(home, '.codex', 'mmx-model-catalog.json'), 'utf8'), + ); + expect(codexCatalog._managed_by).toBe('mmx agent setup'); + expect(codexCatalog.models).toHaveLength(3); + expect(codexCatalog.models[0]).toMatchObject({ + slug: 'MiniMax-M3', + description: 'MiniMax', + priority: 0, + base_instructions: 'You are Codex, a coding agent based on MiniMax-M3. ' + + "You and the user share the same workspace and collaborate to achieve the user's goals.", + shell_type: 'shell_command', + supports_parallel_tool_calls: true, + context_window: 1000000, + max_context_window: 1000000, + input_modalities: ['text', 'image'], + }); + expect(codexCatalog.models[1]).toMatchObject({ + slug: 'MiniMax-M2.7', + priority: 1, + context_window: 204800, + max_context_window: 204800, + input_modalities: ['text'], + }); + expect(codexCatalog.models[2].slug).toBe('MiniMax-M2.7-highspeed'); + expect(codexCatalog.models[0].apply_patch_tool_type).toBeUndefined(); + + const grok = parseToml(readFileSync(join(home, '.grok', 'config.toml'), 'utf8')); + expect((grok.models as Record).default).toBe('minimax'); + expect((grok.model as Record>).minimax.api_backend) + .toBe('chat_completions'); + expect((grok.model as Record>).minimax.max_completion_tokens) + .toBe(128000); + expect((grok.model as Record>)['minimax-m2-7'].model) + .toBe('MiniMax-M2.7'); + expect((grok.model as Record>)['minimax-m2-7-highspeed'].model) + .toBe('MiniMax-M2.7-highspeed'); + + const openCodeText = readFileSync(join(home, '.config', 'opencode', 'opencode.json'), 'utf8'); + const openCode = JSON.parse(openCodeText.replace(/^\s*\/\/.*$/gm, '')); + expect(openCodeText).toContain('// keep this setting'); + expect(openCode.theme).toBe('system'); + expect(openCode.model).toBe('minimax/MiniMax-M3'); + expect(openCode.provider.minimax.options.baseURL).toBe('https://api.minimaxi.com/v1'); + expect(openCode.provider.minimax.options.headers).toEqual({ 'x-keep': 'yes' }); + expect(openCode.provider.minimax.models['keep-model']).toEqual({ name: 'Keep' }); + expect(openCode.provider.minimax.models['MiniMax-M3'].custom).toBe(true); + expect(openCode.provider.minimax.models['MiniMax-M3'].attachment).toBe(true); + expect(openCode.provider.minimax.models['MiniMax-M3'].modalities) + .toEqual({ input: ['text', 'image'], output: ['text'] }); + expect(openCode.provider.minimax.models['MiniMax-M3'].limit) + .toEqual({ context: 1000000, output: 128000 }); + expect(openCode.provider.minimax.models['MiniMax-M2.7'].attachment).toBe(false); + expect(openCode.provider.minimax.models['MiniMax-M2.7'].modalities) + .toEqual({ input: ['text'], output: ['text'] }); + expect(openCode.provider.minimax.models['MiniMax-M2.7'].limit) + .toEqual({ context: 204800, output: 131072 }); + expect(openCode.provider.minimax.models['MiniMax-M2.7-highspeed'].limit) + .toEqual({ context: 204800, output: 131072 }); + + const hermes = parseYaml(readFileSync(join(home, '.hermes', 'config.yaml'), 'utf8')); + expect(hermes.model.provider).toBe('minimax-cn'); + expect(hermes.model.context_length).toBe(1000000); + expect(hermes.model.max_tokens).toBe(128000); + 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')) + .toContain('MINIMAX_CN_API_KEY=sk-test-secret'); + + const piModels = JSON.parse(readFileSync(join(home, '.pi', 'agent', 'models.json'), 'utf8')); + const piSettings = JSON.parse(readFileSync(join(home, '.pi', 'agent', 'settings.json'), 'utf8')); + expect(piModels.providers['minimax-cn'].headers).toEqual({ 'x-keep': 'yes' }); + expect(piModels.providers['minimax-cn'].models[0].id).toBe('keep-model'); + expect(piModels.providers['minimax-cn'].models[1].custom).toBe(true); + expect(piModels.providers['minimax-cn'].models[1].cost.currency).toBe('credits'); + expect(piModels.providers['minimax-cn'].models[1]).toMatchObject({ + id: 'MiniMax-M3', + input: ['text', 'image'], + contextWindow: 1000000, + maxTokens: 128000, + }); + 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)) + .toEqual(['keep-model', 'MiniMax-M3', 'MiniMax-M2.7', 'MiniMax-M2.7-highspeed']); + expect(piSettings).toMatchObject({ defaultProvider: 'minimax-cn', defaultModel: 'MiniMax-M3' }); + + for (const file of result) { + expect(statSync(file.path).mode & 0o777).toBe(0o600); + if (file.backup) { + expect(existsSync(file.backup)).toBe(true); + expect(statSync(file.backup).mode & 0o777).toBe(0o600); + } + } + }); + + it('accepts the scalar model field from a clean Hermes config', () => { + mkdirSync(join(home, '.hermes'), { recursive: true }); + writeFileSync(join(home, '.hermes', 'config.yaml'), 'model: ""\nproviders: {}\n'); + + applyAgentConfigurations(prepareAgentConfigurations(setupOptions(['hermes']))); + + const configured = parseYaml(readFileSync(join(home, '.hermes', 'config.yaml'), 'utf8')); + expect(configured.model).toMatchObject({ default: 'MiniMax-M3', provider: 'minimax' }); + }); + + it('is idempotent and does not create another backup for unchanged files', () => { + const options = setupOptions([...AGENT_IDS]); + applyAgentConfigurations(prepareAgentConfigurations(options)); + const second = applyAgentConfigurations(prepareAgentConfigurations(options)); + + expect(second.every((file) => file.status === 'unchanged')).toBe(true); + expect(second.every((file) => file.backup === undefined)).toBe(true); + }); + + it('adds every MiniMax model to a clean Pi catalog', () => { + const modelsPath = join(home, '.pi', 'agent', 'models.json'); + writeFileSync(modelsPath, '{}\n'); + + applyAgentConfigurations(prepareAgentConfigurations(setupOptions(['pi']))); + + const configured = JSON.parse(readFileSync(modelsPath, 'utf8')); + expect(configured.providers.minimax.models.map((model: { id: string }) => model.id)) + .toEqual(['MiniMax-M3', 'MiniMax-M2.7', 'MiniMax-M2.7-highspeed']); + expect(configured.providers.minimax.models[0]).toMatchObject({ + input: ['text', 'image'], + contextWindow: 1000000, + maxTokens: 128000, + }); + }); + + it('does not write files in dry-run mode', () => { + const prepared = prepareAgentConfigurations(setupOptions(['grok'])); + const result = applyAgentConfigurations(prepared, true); + + expect(result[0]?.status).toBe('would-configure'); + expect(existsSync(join(home, '.grok', 'config.toml'))).toBe(false); + }); + + it('honors GROK_HOME when resolving the Grok config path', () => { + const prepared = prepareAgentConfigurations(setupOptions( + ['grok'], + { env: { GROK_HOME: 'custom-grok' } }, + )); + expect(prepared.map((file) => file.path)) + .toEqual([join(home, 'custom-grok', 'config.toml')]); + }); + + it('uses the active OpenCode config file', () => { + const custom = join(home, 'custom-opencode.jsonc'); + expect(prepareAgentConfigurations(setupOptions( + ['opencode'], + { env: { OPENCODE_CONFIG: custom } }, + )).map((file) => file.path)).toEqual([custom]); + + const json = join(home, '.config', 'opencode', 'opencode.json'); + const jsonc = join(home, '.config', 'opencode', 'opencode.jsonc'); + rmSync(json); + writeFileSync(jsonc, '{ // jsonc\n}\n'); + expect(prepareAgentConfigurations(setupOptions(['opencode'])) + .map((file) => file.path)).toEqual([jsonc]); + + writeFileSync(json, '{}\n'); + expect(() => prepareAgentConfigurations(setupOptions(['opencode']))) + .toThrow('Both OpenCode global config files exist'); + }); + + it('does not use env.HOME as a cross-platform home override', () => { + const prepared = prepareAgentConfigurations(setupOptions( + ['claude-code'], + { homeDir: undefined, env: { HOME: '/unexpected-home' } }, + )); + expect(prepared.map((file) => file.path)) + .toEqual([join(homedir(), '.claude', 'settings.json')]); + }); + + it('rejects malformed existing configuration before any write', () => { + const fakeSecret = 'sk-FAKE-SECRET-IN-MALFORMED-TOML'; + const claudePath = join(home, '.claude', 'settings.json'); + const claudeBefore = readFileSync(claudePath, 'utf8'); + writeFileSync(join(home, '.codex', 'config.toml'), `api_key = "${fakeSecret}" trailing`); + try { + prepareAgentConfigurations(setupOptions(['claude-code', 'codex'])); + throw new Error('Expected malformed TOML to be rejected'); + } catch (error) { + expect((error as Error).message).toContain('not valid TOML'); + expect((error as Error).message).not.toContain(fakeSecret); + } + + expect(readFileSync(claudePath, 'utf8')).toBe(claudeBefore); + }); + + it('does not treat a following TOML array table as part of the target section', () => { + writeFileSync( + join(home, '.codex', 'config.toml'), + '[model_providers.minimax]\nname = "Old"\n\n[[hooks]]\nbase_url = "keep"\n', + ); + applyAgentConfigurations(prepareAgentConfigurations(setupOptions(['codex']))); + + const config = parseToml(readFileSync(join(home, '.codex', 'config.toml'), 'utf8')); + expect((config.hooks as Array>)[0]?.base_url).toBe('keep'); + expect((config.model_providers as Record>).minimax.base_url) + .toBe('https://api.minimax.io/v1'); + }); + + it('preserves inline comments on managed TOML assignments', () => { + const path = join(home, '.codex', 'config.toml'); + writeFileSync( + path, + 'model = "old" # keep root reason\n\n[model_providers.minimax]\nname = "Old" # keep provider reason\n', + ); + + applyAgentConfigurations(prepareAgentConfigurations(setupOptions(['codex']))); + + const config = readFileSync(path, 'utf8'); + expect(config).toContain('model = "MiniMax-M3" # keep root reason'); + expect(config).toContain('name = "MiniMax" # keep provider reason'); + }); + + it('does not replace a user-managed Codex model catalog', () => { + const configPath = join(home, '.codex', 'config.toml'); + const catalogPath = join(home, '.codex', 'my-models.json'); + const config = 'model_catalog_json = "my-models.json"\n'; + const catalog = '{"models":[{"slug":"keep"}]}\n'; + writeFileSync(configPath, config); + writeFileSync(catalogPath, catalog); + + expect(() => prepareAgentConfigurations(setupOptions(['codex']))) + .toThrow('already uses a user-managed model catalog'); + expect(readFileSync(configPath, 'utf8')).toBe(config); + expect(readFileSync(catalogPath, 'utf8')).toBe(catalog); + expect(existsSync(join(home, '.codex', 'mmx-model-catalog.json'))).toBe(false); + }); + + it('does not claim an existing unmarked mmx Codex catalog', () => { + const configPath = join(home, '.codex', 'config.toml'); + const catalogPath = join(home, '.codex', 'mmx-model-catalog.json'); + const catalog = '{"models":[{"slug":"keep"}]}\n'; + writeFileSync(catalogPath, catalog); + + expect(() => prepareAgentConfigurations(setupOptions(['codex']))) + .toThrow('is not managed by mmx'); + expect(readFileSync(configPath, 'utf8')).toBe('[mcp_servers.keep]\ncommand = "keep"\n'); + expect(readFileSync(catalogPath, 'utf8')).toBe(catalog); + }); + + it('rejects TOML multiline strings instead of editing their contents', () => { + const path = join(home, '.codex', 'config.toml'); + const source = 'notes = """\nmodel = keep\n"""\n'; + writeFileSync(path, source); + + expect(() => prepareAgentConfigurations(setupOptions(['codex']))) + .toThrow('contains a multiline string'); + expect(readFileSync(path, 'utf8')).toBe(source); + }); + + it('deduplicates the Hermes API key in dotenv files', () => { + mkdirSync(join(home, '.hermes'), { recursive: true }); + writeFileSync( + join(home, '.hermes', '.env'), + 'export MINIMAX_API_KEY=old-one\nKEEP=yes\nMINIMAX_API_KEY=old-two\n', + ); + applyAgentConfigurations(prepareAgentConfigurations(setupOptions(['hermes']))); + + const dotenv = readFileSync(join(home, '.hermes', '.env'), 'utf8'); + expect(dotenv.match(/^MINIMAX_API_KEY=/gm)?.length).toBe(1); + expect(dotenv).toContain('MINIMAX_API_KEY=sk-test-secret'); + expect(dotenv).toContain('KEEP=yes'); + }); + + it('tightens permissions even when file contents are unchanged', () => { + const options = setupOptions(['claude-code']); + applyAgentConfigurations(prepareAgentConfigurations(options)); + const path = join(home, '.claude', 'settings.json'); + chmodSync(path, 0o644); + + const result = applyAgentConfigurations(prepareAgentConfigurations(options)); + + expect(result[0]?.status).toBe('configured'); + expect(statSync(path).mode & 0o777).toBe(0o600); + }); + + it('rejects duplicate targets before creating files or backups', () => { + const shared = join(home, 'shared-agent-config'); + const prepared = prepareAgentConfigurations(setupOptions( + ['claude-code', 'pi'], + { env: { CLAUDE_CONFIG_DIR: shared, PI_CODING_AGENT_DIR: shared } }, + )); + + expect(() => applyAgentConfigurations(prepared)).toThrow('Multiple agent configuration paths'); + expect(existsSync(shared)).toBe(false); + }); + + it('preserves comments on unrelated Claude model picker entries', () => { + const settingsPath = join(home, '.claude', 'settings.json'); + writeFileSync( + settingsPath, + '{\n "modelPicker": {\n "options": [\n' + + ' // keep this custom model\n' + + ' { "model": "keep-model", "label": "Keep" },\n' + + ' { "model": "MiniMax-M2.7", "label": "Replace" }\n' + + ' ]\n }\n}\n', + ); + + applyAgentConfigurations(prepareAgentConfigurations(setupOptions(['claude-code']))); + + const updated = readFileSync(settingsPath, 'utf8'); + expect(updated).toContain('// keep this custom model'); + expect(updated).toContain('"model": "keep-model"'); + }); + + it('updates a symbolic-link target without replacing the link', () => { + const link = join(home, '.codex', 'config.toml'); + const target = join(home, 'dotfiles', 'codex.toml'); + mkdirSync(join(home, 'dotfiles'), { recursive: true }); + rmSync(link); + writeFileSync(target, '[mcp_servers.keep]\ncommand = "keep"\n'); + symlinkSync(target, link); + + applyAgentConfigurations(prepareAgentConfigurations(setupOptions(['codex'], { region: 'cn' }))); + + expect(lstatSync(link).isSymbolicLink()).toBe(true); + expect(readFileSync(target, 'utf8')).toContain('https://api.minimaxi.com/v1'); + }); + + it('rejects a symbolic link whose target changed after preparation', () => { + const link = join(home, '.codex', 'config.toml'); + const first = join(home, 'dotfiles', 'first.toml'); + const second = join(home, 'dotfiles', 'second.toml'); + const source = '[mcp_servers.keep]\ncommand = "keep"\n'; + mkdirSync(join(home, 'dotfiles'), { recursive: true }); + rmSync(link); + writeFileSync(first, source); + writeFileSync(second, source); + symlinkSync(first, link); + const prepared = prepareAgentConfigurations(setupOptions(['codex'])); + rmSync(link); + symlinkSync(second, link); + + expect(() => applyAgentConfigurations(prepared)).toThrow('Configuration changed'); + expect(readFileSync(first, 'utf8')).toBe(source); + expect(readFileSync(second, 'utf8')).toBe(source); + }); + + it('rejects a parent-directory link whose target changed after preparation', () => { + const link = join(home, '.codex'); + const first = join(home, 'dotfiles-one'); + const second = join(home, 'dotfiles-two'); + const source = '[mcp_servers.keep]\ncommand = "keep"\n'; + rmSync(link, { recursive: true }); + for (const directory of [first, second]) { + mkdirSync(directory); + writeFileSync(join(directory, 'config.toml'), source); + } + symlinkSync(first, link, 'dir'); + const prepared = prepareAgentConfigurations(setupOptions(['codex'])); + rmSync(link); + symlinkSync(second, link, 'dir'); + + expect(() => applyAgentConfigurations(prepared)).toThrow('Configuration changed'); + expect(readFileSync(join(first, 'config.toml'), 'utf8')).toBe(source); + expect(readFileSync(join(second, 'config.toml'), 'utf8')).toBe(source); + }); +}); diff --git a/test/agent/verify.test.ts b/test/agent/verify.test.ts new file mode 100644 index 0000000..f56ca73 --- /dev/null +++ b/test/agent/verify.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it } from 'bun:test'; + +import { verifyAgentCredential } from '../../src/agent/verify'; + +describe('agent credential verification', () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('does not echo an API key reflected by an upstream error', async () => { + const apiKey = 'sensitive-test-value'; + globalThis.fetch = (async () => new Response(JSON.stringify({ + error: { message: `Rejected ${apiKey}` }, + }), { status: 401 })) as unknown as typeof fetch; + + let caught: unknown; + try { + await verifyAgentCredential({ apiKey, region: 'global', model: 'MiniMax-M3' }); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).message).not.toContain(apiKey); + }); + + it('requests a stream so verification does not wait for full generation', async () => { + let requestBody: Record | undefined; + globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => { + requestBody = JSON.parse(String(init?.body)); + return new Response('data: {"type":"response.created","response":' + + '{"id":"resp_test","model":"MiniMax-M3"}}\n\n'); + }) as unknown as typeof fetch; + + const result = await verifyAgentCredential({ + apiKey: 'sensitive-test-value', + region: 'global', + model: 'MiniMax-M3', + }); + + expect(requestBody?.stream).toBe(true); + expect(result.status).toBe('ok'); + }); + + 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; + + await expect(verifyAgentCredential({ + apiKey: 'sensitive-test-value', + region: 'global', + model: 'MiniMax-M3', + })).rejects.toThrow('invalid agent verification response'); + }); +}); diff --git a/test/args.test.ts b/test/args.test.ts index e196d3e..b193a27 100644 --- a/test/args.test.ts +++ b/test/args.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'bun:test'; -import { parseFlags } from '../src/args'; -import type { OptionDef } from '../src/command'; +import { findOptionError, parseFlags, scanCommandPath } from '../src/args'; +import { GLOBAL_OPTIONS, type OptionDef } from '../src/command'; const OPTIONS: OptionDef[] = [ { flag: '--timeout ', description: 'Request timeout', type: 'number' }, @@ -53,3 +53,37 @@ describe('parseFlags', () => { ); }); }); + +describe('scanCommandPath', () => { + it('does not mistake a value after a command-local boolean for a positional', () => { + const agentOptions: OptionDef[] = [ + { flag: '--all', description: 'All agents' }, + ]; + + expect(scanCommandPath( + ['agent', 'setup', '--all', '--api-key', 'secret', '--region', 'cn'], + [...GLOBAL_OPTIONS, ...agentOptions], + )).toEqual(['agent', 'setup']); + }); + + it('reports an unknown option before it can consume a safety flag', () => { + expect(findOptionError( + ['agent', 'setup', '--skip-verfiy', '--dry-run'], + GLOBAL_OPTIONS, + )).toBe('Unknown option: --skip-verfiy'); + }); + + it('rejects a missing value before it can consume a safety flag', () => { + expect(findOptionError( + ['agent', 'setup', '--api-key', '--dry-run'], + GLOBAL_OPTIONS, + )).toBe('Option --api-key requires a value.'); + }); + + it('rejects empty equals-form values', () => { + for (const option of ['--output=', '--base-url= ']) { + expect(findOptionError(['agent', 'setup', option], GLOBAL_OPTIONS)) + .toContain('requires a value'); + } + }); +}); diff --git a/test/commands/agent/setup.test.ts b/test/commands/agent/setup.test.ts new file mode 100644 index 0000000..49f7e36 --- /dev/null +++ b/test/commands/agent/setup.test.ts @@ -0,0 +1,287 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import setupCommand from '../../../src/commands/agent/setup'; +import { registry } from '../../../src/registry'; +import type { Config } from '../../../src/config/schema'; +import type { GlobalFlags } from '../../../src/types/flags'; + +function testConfig(overrides: Partial = {}): Config { + return { + region: 'cn', + baseUrl: 'https://api.minimaxi.com', + output: 'json', + timeout: 30, + verbose: false, + quiet: true, + noColor: true, + yes: false, + dryRun: true, + nonInteractive: true, + async: false, + ...overrides, + }; +} + +function testFlags(overrides: Partial = {}): GlobalFlags { + return { + quiet: false, + verbose: false, + noColor: false, + yes: false, + dryRun: true, + help: false, + nonInteractive: false, + async: false, + ...overrides, + }; +} + +async function captureConsoleLog(task: () => Promise): Promise { + const originalLog = console.log; + let output = ''; + console.log = (message: string) => { output += message; }; + try { + await task(); + return output; + } finally { + console.log = originalLog; + } +} + +describe('agent setup command', () => { + let home: string; + let originalHome: string | undefined; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'mmx-agent-command-')); + originalHome = process.env.HOME; + process.env.HOME = home; + }); + + afterEach(() => { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + rmSync(home, { recursive: true, force: true }); + }); + + it('is registered under agent setup', () => { + expect(registry.resolve(['agent', 'setup']).command).toBe(setupCommand); + }); + + it('explains the supported credentials in command help metadata', () => { + expect(setupCommand.description).toContain('using a MiniMax API key'); + const apiKeyOption = setupCommand.options?.find(option => option.flag === '--api-key '); + expect(apiKeyOption?.description).toContain('Token Plan (sk-cp)'); + expect(apiKeyOption?.description).toContain('pay-as-you-go (sk-api)'); + expect(apiKeyOption?.description).toContain('not interchangeable'); + expect(setupCommand.options?.some(option => option.flag === '--skip-verify')).toBe(false); + }); + + it('supports scriptable dry-run output without exposing the key', async () => { + const output = await captureConsoleLog(() => setupCommand.execute( + testConfig(), + testFlags({ + agent: ['codex'], + apiKey: 'sk-test-secret', + region: 'cn', + output: 'json', + }), + )); + + const parsed = JSON.parse(output); + expect(parsed.agents).toEqual(['codex']); + expect(parsed.files[0].status).toBe('would-configure'); + expect(output).not.toContain('sk-test-secret'); + expect(output).not.toContain('\x1b'); + }); + + it('requires an explicit region for scripts', async () => { + await expect(setupCommand.execute( + testConfig({ region: 'global', baseUrl: 'https://api.minimax.io' }), + testFlags({ + agent: ['codex'], + apiKey: 'sk-test-secret', + }), + )).rejects.toThrow('--region global|cn is required'); + }); + + it('requires an API key for agent setup', async () => { + await expect(setupCommand.execute( + testConfig(), + testFlags({ + agent: ['codex'], + region: 'cn', + }), + )).rejects.toThrow('A MiniMax API key is required'); + }); + + it('does not reuse an API key saved for mmx', async () => { + await expect(setupCommand.execute( + testConfig({ fileApiKey: 'sk-saved-mmx-key' }), + testFlags({ + agent: ['codex'], + region: 'cn', + }), + )).rejects.toThrow('A MiniMax API key is required'); + }); + + it('routes the shorthand agent command without invoking global auth setup', async () => { + const child = Bun.spawn({ + cmd: [process.execPath, 'run', 'src/main.ts', 'agent', '--non-interactive', '--dry-run'], + cwd: process.cwd(), + env: { + ...process.env, + HOME: home, + MMX_CONFIG_DIR: join(home, '.mmx'), + NO_COLOR: '1', + }, + stdout: 'ignore', + stderr: 'pipe', + }); + const stderr = await new Response(child.stderr).text(); + await child.exited; + + expect(stderr).toContain('At least one --agent or --all is required'); + expect(stderr).not.toContain('How would you like to authenticate'); + }); + + it('shows setup options for the shorthand agent help', async () => { + const child = Bun.spawn({ + cmd: [process.execPath, 'run', 'src/main.ts', 'agent', '--help'], + cwd: process.cwd(), + env: { ...process.env, HOME: home, MMX_CONFIG_DIR: join(home, '.mmx'), NO_COLOR: '1' }, + stdout: 'ignore', + stderr: 'pipe', + }); + const stderr = await new Response(child.stderr).text(); + await child.exited; + + expect(stderr).toContain('Usage: mmx agent setup'); + expect(stderr).toContain('--api-key '); + expect(stderr).toContain('not interchangeable'); + }); + + it('keeps non-interactive text output free of prompt colors', async () => { + const output = await captureConsoleLog(() => setupCommand.execute( + testConfig({ output: 'text', noColor: false }), + testFlags({ + agent: ['codex'], + apiKey: 'sk-test-secret', + region: 'cn', + output: 'text', + }), + )); + + expect(output).not.toContain('\x1b'); + }); + + it('does not enter the wizard when an option was explicitly set to false', async () => { + await expect(setupCommand.execute( + testConfig({ + region: 'global', + baseUrl: 'https://api.minimax.io', + dryRun: false, + nonInteractive: false, + }), + testFlags({ + dryRun: false, + _hasExplicitOptions: true, + }), + )).rejects.toThrow('At least one --agent or --all is required'); + }); + + it('rejects positional agent names instead of silently ignoring them', async () => { + await expect(setupCommand.execute( + testConfig(), + testFlags({ + agent: ['claude-code'], + apiKey: 'sk-test-secret', + region: 'cn', + _positional: ['codex'], + }), + )).rejects.toThrow('Unexpected positional argument.'); + }); + + it('accepts a supported model as the default', async () => { + const output = await captureConsoleLog(() => setupCommand.execute( + testConfig(), + testFlags({ + agent: ['codex'], + apiKey: 'sk-test-secret', + region: 'cn', + model: 'MiniMax-M2.7', + }), + )); + expect(JSON.parse(output).verification.model).toBe('MiniMax-M2.7'); + }); + + it('rejects a legacy model outside this setup contract', async () => { + await expect(setupCommand.execute( + testConfig(), + testFlags({ + agent: ['codex'], + apiKey: 'sk-test-secret', + region: 'cn', + model: 'MiniMax-M2.5', + }), + )).rejects.toThrow('Unsupported MiniMax model "MiniMax-M2.5"'); + }); + + it('accepts grok-build as an alias', async () => { + const output = await captureConsoleLog(() => setupCommand.execute( + testConfig(), + testFlags({ + agent: ['grok-build'], + apiKey: 'sk-test-secret', + region: 'cn', + }), + )); + expect(JSON.parse(output).agents).toEqual(['grok']); + }); + + it('validates --agent values even when --all is present', async () => { + await expect(setupCommand.execute( + testConfig(), + testFlags({ + all: true, + agent: ['typo'], + apiKey: 'sk-test-secret', + region: 'cn', + }), + )).rejects.toThrow('Unsupported agent "typo"'); + }); + + it('warns scripts when a selected agent is not detected on PATH', async () => { + const originalPath = process.env.PATH; + const originalWrite = process.stderr.write.bind(process.stderr); + let stderr = ''; + process.env.PATH = home; + (process.stderr as NodeJS.WriteStream).write = (chunk: unknown) => { + stderr += String(chunk); + return true; + }; + + try { + await captureConsoleLog(() => setupCommand.execute( + testConfig({ quiet: false }), + testFlags({ + agent: ['pi'], + apiKey: 'sk-test-secret', + region: 'cn', + }), + )); + } finally { + if (originalPath === undefined) delete process.env.PATH; + else process.env.PATH = originalPath; + (process.stderr as NodeJS.WriteStream).write = originalWrite; + } + + expect(stderr).toBe( + 'Warning: Not detected on PATH: Pi. ' + + 'mmx can write configuration files for them, but will not download or install them for you.\n', + ); + }); +}); diff --git a/test/utils/prompt.test.ts b/test/utils/prompt.test.ts new file mode 100644 index 0000000..f1f174e --- /dev/null +++ b/test/utils/prompt.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from 'bun:test'; + +import { apiKeyPreview } from '../../src/utils/prompt'; + +describe('apiKeyPreview', () => { + it('shows the total length only when the key is truncated', () => { + expect(apiKeyPreview('abc')).toBe('abc'); + expect(apiKeyPreview('a'.repeat(30))).toBe('a'.repeat(30)); + expect(apiKeyPreview('a'.repeat(125))).toBe(`${'a'.repeat(30)}.... (125 chars)`); + }); +});