feat: auto-detect gemini binary path, LLM health check in status

- Setup wizard auto-detects gemini binary via `which`, saves full path
  so systemd service can find it without user PATH
- `mcpctl status` tests LLM provider health (gemini: quick prompt test,
  ollama: health check, API providers: key stored confirmation)
- Shows error details inline: "gemini-cli / gemini-2.5-flash (not authenticated)"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-02-24 23:24:31 +00:00
parent 3ff39ff1ee
commit 36cd0bbec4
4 changed files with 158 additions and 26 deletions

View File

@@ -42,6 +42,7 @@ function buildDeps(overrides: {
secrets?: Record<string, string>;
answers?: unknown[];
fetchModels?: ConfigSetupDeps['fetchModels'];
whichBinary?: ConfigSetupDeps['whichBinary'];
} = {}): ConfigSetupDeps {
return {
configDeps: { configDir: tempDir },
@@ -49,6 +50,7 @@ function buildDeps(overrides: {
log: (...args: string[]) => logs.push(args.join(' ')),
prompt: mockPrompt(overrides.answers ?? []),
fetchModels: overrides.fetchModels ?? vi.fn(async () => []),
whichBinary: overrides.whichBinary ?? vi.fn(async () => '/usr/bin/gemini'),
};
}
@@ -76,26 +78,49 @@ describe('config setup wizard', () => {
});
describe('provider: gemini-cli', () => {
it('saves gemini-cli with selected model', async () => {
// Answers: select provider, select model, confirm custom binary=false
const deps = buildDeps({ answers: ['gemini-cli', 'gemini-2.5-flash', false] });
it('auto-detects binary path and saves config', async () => {
// Answers: select provider, select model (no binary prompt — auto-detected)
const deps = buildDeps({
answers: ['gemini-cli', 'gemini-2.5-flash'],
whichBinary: vi.fn(async () => '/home/user/.npm-global/bin/gemini'),
});
await runSetup(deps);
const config = readConfig();
expect((config.llm as Record<string, unknown>).provider).toBe('gemini-cli');
expect((config.llm as Record<string, unknown>).model).toBe('gemini-2.5-flash');
const llm = config.llm as Record<string, unknown>;
expect(llm.provider).toBe('gemini-cli');
expect(llm.model).toBe('gemini-2.5-flash');
expect(llm.binaryPath).toBe('/home/user/.npm-global/bin/gemini');
expect(logs.some((l) => l.includes('Found gemini at'))).toBe(true);
cleanup();
});
it('saves gemini-cli with custom model and binary path', async () => {
// Answers: select provider, select custom, enter model name, confirm custom binary=true, enter path
const deps = buildDeps({ answers: ['gemini-cli', '__custom__', 'gemini-3.0-flash', true, '/opt/gemini'] });
it('prompts for manual path when binary not found', async () => {
// Answers: select provider, select model, enter manual path
const deps = buildDeps({
answers: ['gemini-cli', 'gemini-2.5-flash', '/opt/gemini'],
whichBinary: vi.fn(async () => null),
});
await runSetup(deps);
const config = readConfig();
const llm = config.llm as Record<string, unknown>;
expect(llm.binaryPath).toBe('/opt/gemini');
expect(logs.some((l) => l.includes('not found'))).toBe(true);
cleanup();
});
it('saves gemini-cli with custom model', async () => {
// Answers: select provider, select custom, enter model name
const deps = buildDeps({
answers: ['gemini-cli', '__custom__', 'gemini-3.0-flash'],
whichBinary: vi.fn(async () => '/usr/bin/gemini'),
});
await runSetup(deps);
const config = readConfig();
const llm = config.llm as Record<string, unknown>;
expect(llm.model).toBe('gemini-3.0-flash');
expect(llm.binaryPath).toBe('/opt/gemini');
cleanup();
});
});
@@ -250,7 +275,7 @@ describe('config setup wizard', () => {
describe('output messages', () => {
it('shows restart instruction', async () => {
const deps = buildDeps({ answers: ['gemini-cli', 'gemini-2.5-flash', false] });
const deps = buildDeps({ answers: ['gemini-cli', 'gemini-2.5-flash'] });
await runSetup(deps);
expect(logs.some((l) => l.includes('systemctl --user restart mcplocal'))).toBe(true);
@@ -258,7 +283,7 @@ describe('config setup wizard', () => {
});
it('shows configured provider and model', async () => {
const deps = buildDeps({ answers: ['gemini-cli', 'gemini-2.5-flash', false] });
const deps = buildDeps({ answers: ['gemini-cli', 'gemini-2.5-flash'] });
await runSetup(deps);
expect(logs.some((l) => l.includes('gemini-cli') && l.includes('gemini-2.5-flash'))).toBe(true);

View File

@@ -141,18 +141,48 @@ describe('status command', () => {
expect(out).toContain('mcpctl config setup');
});
it('shows configured LLM provider and model', async () => {
it('shows configured LLM provider and model when healthy', async () => {
saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'anthropic', model: 'claude-haiku-3-5-20241022' } }, { configDir: tempDir });
const cmd = createStatusCommand({
configDeps: { configDir: tempDir },
credentialsDeps: { configDir: tempDir },
log,
checkHealth: async () => true,
checkLlm: async () => 'ok',
});
await cmd.parseAsync([], { from: 'user' });
const out = output.join('\n');
expect(out).toContain('LLM:');
expect(out).toContain('anthropic / claude-haiku-3-5-20241022');
// Should NOT show error status when ok
expect(out).not.toContain('(ok)');
});
it('shows LLM error status when check fails', async () => {
saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'gemini-cli', model: 'gemini-2.5-flash' } }, { configDir: tempDir });
const cmd = createStatusCommand({
configDeps: { configDir: tempDir },
credentialsDeps: { configDir: tempDir },
log,
checkHealth: async () => true,
checkLlm: async () => 'not authenticated',
});
await cmd.parseAsync([], { from: 'user' });
const out = output.join('\n');
expect(out).toContain('gemini-cli / gemini-2.5-flash (not authenticated)');
});
it('shows binary not found status', async () => {
saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'gemini-cli', model: 'gemini-2.5-flash' } }, { configDir: tempDir });
const cmd = createStatusCommand({
configDeps: { configDir: tempDir },
credentialsDeps: { configDir: tempDir },
log,
checkHealth: async () => true,
checkLlm: async () => 'binary not found',
});
await cmd.parseAsync([], { from: 'user' });
expect(output.join('\n')).toContain('(binary not found)');
});
it('shows not configured when LLM provider is none', async () => {
@@ -167,17 +197,19 @@ describe('status command', () => {
expect(output.join('\n')).toContain('not configured');
});
it('includes llm field in JSON output', async () => {
it('includes llm and llmStatus in JSON output', async () => {
saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'gemini-cli', model: 'gemini-2.5-flash' } }, { configDir: tempDir });
const cmd = createStatusCommand({
configDeps: { configDir: tempDir },
credentialsDeps: { configDir: tempDir },
log,
checkHealth: async () => true,
checkLlm: async () => 'ok',
});
await cmd.parseAsync(['-o', 'json'], { from: 'user' });
const parsed = JSON.parse(output[0]) as Record<string, unknown>;
expect(parsed['llm']).toBe('gemini-cli / gemini-2.5-flash');
expect(parsed['llmStatus']).toBe('ok');
});
it('includes null llm in JSON output when not configured', async () => {
@@ -190,5 +222,6 @@ describe('status command', () => {
await cmd.parseAsync(['-o', 'json'], { from: 'user' });
const parsed = JSON.parse(output[0]) as Record<string, unknown>;
expect(parsed['llm']).toBeNull();
expect(parsed['llmStatus']).toBeNull();
});
});