Skip to content

Commit 3fbd872

Browse files
committed
feat: show coding agent availability
1 parent dd8a182 commit 3fbd872

6 files changed

Lines changed: 174 additions & 10 deletions

File tree

README.md

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

193-
The command verifies the key and selected region before writing. Existing files are backed up when changed; use `--dry-run` to preview or `--skip-verify` to skip the live request.
193+
The command verifies the key and selected region before writing. Existing files are backed up when changed; use `--dry-run` to preview or `--skip-verify` to skip the live request. Agent setup only writes configuration files; it does not install or launch the selected agents.
194194

195195
### `mmx update`
196196

README_CN.md

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

169-
写入前会验证 Key 和所选区域;修改已有文件时会创建备份。可用 `--dry-run` 预览,或用 `--skip-verify` 跳过联网验证。
169+
写入前会验证 Key 和所选区域;修改已有文件时会创建备份。可用 `--dry-run` 预览,或用 `--skip-verify` 跳过联网验证。该命令只管理配置文件,不会安装或启动所选 Agent。
170170

171171
### `mmx update`
172172

src/agent/availability.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { accessSync, constants, statSync } from 'fs';
2+
import { delimiter, join } from 'path';
3+
4+
import { AGENT_IDS, type AgentId } from './types';
5+
6+
const AGENT_EXECUTABLES: Record<AgentId, readonly string[]> = {
7+
'claude-code': ['claude'],
8+
codex: ['codex'],
9+
grok: ['grok', 'grok-build', 'grok-cli'],
10+
opencode: ['opencode'],
11+
hermes: ['hermes'],
12+
pi: ['pi'],
13+
};
14+
15+
export function detectAgentsOnPath(env: NodeJS.ProcessEnv = process.env): Set<AgentId> {
16+
const pathValue = process.platform === 'win32' ? env.PATH ?? env.Path : env.PATH;
17+
if (pathValue === undefined) return new Set();
18+
const directories = pathValue
19+
.split(delimiter)
20+
.map((directory) => directory.replace(/^"|"$/g, ''));
21+
const extensions = process.platform === 'win32'
22+
? (env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';')
23+
: [''];
24+
const accessMode = process.platform === 'win32' ? constants.F_OK : constants.X_OK;
25+
26+
return new Set(AGENT_IDS.filter((agent) => AGENT_EXECUTABLES[agent].some(
27+
(command) => directories.some((directory) => extensions.some((extension) => {
28+
const candidate = join(directory, `${command}${extension}`);
29+
try {
30+
if (!statSync(candidate).isFile()) return false;
31+
accessSync(candidate, accessMode);
32+
return true;
33+
} catch {
34+
return false;
35+
}
36+
})),
37+
)));
38+
}

src/commands/agent/setup.ts

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
applyAgentConfigurations,
44
prepareAgentConfigurations,
55
} from '../../agent/configurator';
6+
import { detectAgentsOnPath } from '../../agent/availability';
67
import {
78
AGENT_IDS,
89
type AgentId,
@@ -44,6 +45,7 @@ const AGENT_LABELS: Record<AgentId, string> = {
4445
hermes: 'Hermes Agent',
4546
pi: 'Pi',
4647
};
48+
const DEFAULT_INTERACTIVE_AGENTS: AgentId[] = ['claude-code', 'codex'];
4749

4850
function uniqueAgents(values: string[]): AgentId[] {
4951
const result: AgentId[] = [];
@@ -69,11 +71,17 @@ function isInteractiveInvocation(flags: GlobalFlags): boolean {
6971

7072
async function interactiveOptions(
7173
config: Config,
74+
detectedAgents: Set<AgentId>,
7275
): Promise<AgentSetupOptions> {
7376
const selectedAgents = await promptMultiSelect({
7477
message: 'Select agents to configure',
75-
choices: AGENT_IDS.map((agent) => ({ value: agent, label: AGENT_LABELS[agent] })),
76-
initialValues: ['claude-code', 'codex'],
78+
choices: AGENT_IDS.map((agent) => ({
79+
value: agent,
80+
label: detectedAgents.has(agent)
81+
? AGENT_LABELS[agent]
82+
: `${AGENT_LABELS[agent]} (not detected on PATH)`,
83+
})),
84+
initialValues: DEFAULT_INTERACTIVE_AGENTS.filter((agent) => detectedAgents.has(agent)),
7785
required: true,
7886
});
7987
if (!selectedAgents?.length) {
@@ -105,9 +113,13 @@ async function interactiveOptions(
105113
}
106114
if (!apiKey) throw new CLIError('An API key is required.', ExitCode.USAGE);
107115

108-
const confirmed = await promptConfirm({
109-
message: `Configure ${agents.map((agent) => AGENT_LABELS[agent]).join(', ')}?`,
110-
});
116+
const notDetected = agents.filter((agent) => !detectedAgents.has(agent));
117+
let message = `Configure ${agents.map((agent) => AGENT_LABELS[agent]).join(', ')}? `
118+
+ 'mmx will only write configuration files; it will not install or launch agents.';
119+
if (notDetected.length > 0) {
120+
message += ` Not detected on PATH: ${notDetected.map((agent) => AGENT_LABELS[agent]).join(', ')}.`;
121+
}
122+
const confirmed = await promptConfirm({ message });
111123
if (!confirmed) throw new CLIError('Agent setup cancelled.', ExitCode.GENERAL);
112124

113125
return { agents, apiKey, region: selectedRegion, model: 'MiniMax-M3' };
@@ -166,7 +178,7 @@ function nonInteractiveOptions(
166178

167179
export default defineCommand({
168180
name: 'agent setup',
169-
description: 'Configure MiniMax for external coding agents',
181+
description: 'Configure external coding agents (does not install or launch them)',
170182
usage: 'mmx agent setup [--agent <name> ... | --all] [--api-key <key>] [--region <region>]',
171183
options: [
172184
{
@@ -185,8 +197,9 @@ export default defineCommand({
185197
'mmx agent setup --agent opencode --api-key <key> --region cn --dry-run',
186198
],
187199
async run(config: Config, flags: GlobalFlags) {
200+
const detectedAgents = detectAgentsOnPath();
188201
const options = isInteractiveInvocation(flags)
189-
? await interactiveOptions(config)
202+
? await interactiveOptions(config, detectedAgents)
190203
: nonInteractiveOptions(config, flags);
191204

192205
let verification: AgentVerification = {
@@ -212,5 +225,12 @@ export default defineCommand({
212225
agents: options.agents,
213226
files,
214227
}, format));
228+
const notDetected = options.agents.filter((agent) => !detectedAgents.has(agent));
229+
if (notDetected.length > 0 && !config.quiet) {
230+
process.stderr.write(
231+
`Warning: Not detected on PATH: ${notDetected.map((agent) => AGENT_LABELS[agent]).join(', ')}. `
232+
+ 'mmx only manages configuration; it does not install or launch agents.\n',
233+
);
234+
}
215235
},
216236
});

test/agent/availability.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { afterEach, describe, expect, it } from 'bun:test';
2+
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
3+
import { tmpdir } from 'os';
4+
import { join } from 'path';
5+
6+
import { detectAgentsOnPath } from '../../src/agent/availability';
7+
8+
describe('agent availability', () => {
9+
const roots: string[] = [];
10+
11+
afterEach(() => {
12+
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
13+
});
14+
15+
it('detects supported executable names without treating directories as commands', () => {
16+
const root = mkdtempSync(join(tmpdir(), 'mmx-agent-path-'));
17+
roots.push(root);
18+
const suffix = process.platform === 'win32' ? '.CMD' : '';
19+
const executable = join(root, `pi${suffix}`);
20+
writeFileSync(executable, '');
21+
chmodSync(executable, 0o700);
22+
mkdirSync(join(root, `codex${suffix}`));
23+
24+
const detected = detectAgentsOnPath({
25+
PATH: root,
26+
PATHEXT: '.COM;.EXE;.BAT;.CMD',
27+
});
28+
29+
expect(detected).toEqual(new Set(['pi']));
30+
});
31+
32+
it('recognizes every supported Grok executable name', () => {
33+
const suffix = process.platform === 'win32' ? '.CMD' : '';
34+
for (const command of ['grok', 'grok-build', 'grok-cli']) {
35+
const root = mkdtempSync(join(tmpdir(), 'mmx-agent-path-'));
36+
roots.push(root);
37+
const executable = join(root, `${command}${suffix}`);
38+
writeFileSync(executable, '');
39+
chmodSync(executable, 0o700);
40+
41+
expect(detectAgentsOnPath({ PATH: root, PATHEXT: '.COM;.EXE;.BAT;.CMD' }).has('grok'))
42+
.toBe(true);
43+
}
44+
});
45+
46+
it('does not scan the working directory when PATH is missing', () => {
47+
const root = mkdtempSync(join(tmpdir(), 'mmx-agent-path-'));
48+
roots.push(root);
49+
const originalCwd = process.cwd();
50+
const suffix = process.platform === 'win32' ? '.CMD' : '';
51+
const executable = join(root, `codex${suffix}`);
52+
writeFileSync(executable, '');
53+
chmodSync(executable, 0o700);
54+
process.chdir(root);
55+
try {
56+
expect(detectAgentsOnPath({})).toEqual(new Set());
57+
} finally {
58+
process.chdir(originalCwd);
59+
}
60+
});
61+
62+
it('does not treat Path as PATH on case-sensitive platforms', () => {
63+
if (process.platform === 'win32') return;
64+
const root = mkdtempSync(join(tmpdir(), 'mmx-agent-path-'));
65+
roots.push(root);
66+
const executable = join(root, 'pi');
67+
writeFileSync(executable, '');
68+
chmodSync(executable, 0o700);
69+
70+
expect(detectAgentsOnPath({ Path: root })).toEqual(new Set());
71+
});
72+
});

test/commands/agent/setup.test.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ function testConfig(overrides: Partial<Config> = {}): Config {
1515
output: 'json',
1616
timeout: 30,
1717
verbose: false,
18-
quiet: false,
18+
quiet: true,
1919
noColor: true,
2020
yes: false,
2121
dryRun: true,
@@ -165,4 +165,38 @@ describe('agent setup command', () => {
165165
)).rejects.toThrow('Unsupported agent "typo"');
166166
});
167167

168+
it('warns on stderr when a selected agent is not detected on PATH', async () => {
169+
const originalPath = process.env.PATH;
170+
const originalLog = console.log;
171+
const originalWrite = process.stderr.write.bind(process.stderr);
172+
let stderr = '';
173+
process.env.PATH = home;
174+
console.log = () => {};
175+
(process.stderr as NodeJS.WriteStream).write = (chunk: unknown) => {
176+
stderr += String(chunk);
177+
return true;
178+
};
179+
180+
try {
181+
await setupCommand.execute(
182+
testConfig({ quiet: false }),
183+
testFlags({
184+
agent: ['pi'],
185+
apiKey: 'sk-test-secret',
186+
region: 'cn',
187+
}),
188+
);
189+
} finally {
190+
if (originalPath === undefined) delete process.env.PATH;
191+
else process.env.PATH = originalPath;
192+
console.log = originalLog;
193+
(process.stderr as NodeJS.WriteStream).write = originalWrite;
194+
}
195+
196+
expect(stderr).toBe(
197+
'Warning: Not detected on PATH: Pi. '
198+
+ 'mmx only manages configuration; it does not install or launch agents.\n',
199+
);
200+
});
201+
168202
});

0 commit comments

Comments
 (0)