feat(cli): add /doctor diagnostic command (#3404)

Closes #3018
This commit is contained in:
jinye 2026-04-19 19:25:55 +08:00 committed by GitHub
parent c175fd3d4a
commit 9de33dded3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1016 additions and 1 deletions

View file

@ -38,6 +38,7 @@ const debugLogger = createDebugLogger('NON_INTERACTIVE_COMMANDS');
* - summary: Generate session summary
* - compress: Compress conversation history
* - context: Show context window usage (read-only diagnostic)
* - doctor: Run installation and environment diagnostics (read-only diagnostic)
*/
export const ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE = [
'init',
@ -46,6 +47,7 @@ export const ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE = [
'btw',
'bug',
'context',
'doctor',
] as const;
/**

View file

@ -19,6 +19,7 @@ import { compressCommand } from '../ui/commands/compressCommand.js';
import { contextCommand } from '../ui/commands/contextCommand.js';
import { copyCommand } from '../ui/commands/copyCommand.js';
import { docsCommand } from '../ui/commands/docsCommand.js';
import { doctorCommand } from '../ui/commands/doctorCommand.js';
import { directoryCommand } from '../ui/commands/directoryCommand.js';
import { editorCommand } from '../ui/commands/editorCommand.js';
import { exportCommand } from '../ui/commands/exportCommand.js';
@ -96,6 +97,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
contextCommand,
copyCommand,
docsCommand,
doctorCommand,
directoryCommand,
editorCommand,
exportCommand,

View file

@ -0,0 +1,142 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { doctorCommand } from './doctorCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import * as doctorChecksModule from '../../utils/doctorChecks.js';
import type { DoctorCheckResult } from '../types.js';
vi.mock('../../utils/doctorChecks.js');
describe('doctorCommand', () => {
let mockContext: CommandContext;
const mockChecks: DoctorCheckResult[] = [
{
category: 'System',
name: 'Node.js version',
status: 'pass',
message: 'v20.0.0',
},
{
category: 'Authentication',
name: 'API key',
status: 'fail',
message: 'not configured',
detail: 'Run /auth to configure authentication.',
},
];
beforeEach(() => {
mockContext = createMockCommandContext({
executionMode: 'interactive',
ui: {
addItem: vi.fn(),
setPendingItem: vi.fn(),
},
} as unknown as CommandContext);
vi.mocked(doctorChecksModule.runDoctorChecks).mockResolvedValue(mockChecks);
});
afterEach(() => {
vi.clearAllMocks();
});
it('should have the correct name and description', () => {
expect(doctorCommand.name).toBe('doctor');
expect(doctorCommand.description).toBe(
'Run installation and environment diagnostics',
);
});
it('should show pending item and then add doctor item in interactive mode', async () => {
await doctorCommand.action!(mockContext, '');
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(
expect.objectContaining({ text: 'Running diagnostics...' }),
);
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'doctor',
checks: mockChecks,
summary: { pass: 1, warn: 0, fail: 1 },
}),
expect.any(Number),
);
});
it('should return JSON message in non-interactive mode', async () => {
mockContext = createMockCommandContext({
executionMode: 'non_interactive',
ui: {
addItem: vi.fn(),
setPendingItem: vi.fn(),
},
} as unknown as CommandContext);
const result = await doctorCommand.action!(mockContext, '');
expect(result).toEqual(
expect.objectContaining({
type: 'message',
messageType: 'error',
}),
);
expect(mockContext.ui.addItem).not.toHaveBeenCalled();
});
it('should return info messageType when no failures', async () => {
vi.mocked(doctorChecksModule.runDoctorChecks).mockResolvedValue([
{
category: 'System',
name: 'Node.js version',
status: 'pass',
message: 'v20.0.0',
},
]);
mockContext = createMockCommandContext({
executionMode: 'non_interactive',
ui: {
addItem: vi.fn(),
setPendingItem: vi.fn(),
},
} as unknown as CommandContext);
const result = await doctorCommand.action!(mockContext, '');
expect(result).toEqual(
expect.objectContaining({
type: 'message',
messageType: 'info',
}),
);
});
it('should not add item when aborted', async () => {
const abortController = new AbortController();
abortController.abort();
mockContext = createMockCommandContext({
executionMode: 'interactive',
abortSignal: abortController.signal,
ui: {
addItem: vi.fn(),
setPendingItem: vi.fn(),
},
} as unknown as CommandContext);
await doctorCommand.action!(mockContext, '');
expect(mockContext.ui.addItem).not.toHaveBeenCalled();
// setPendingItem(null) should still be called via finally
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null);
});
});

View file

@ -0,0 +1,64 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type { SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import type { HistoryItemDoctor } from '../types.js';
import { runDoctorChecks } from '../../utils/doctorChecks.js';
import { t } from '../../i18n/index.js';
export const doctorCommand: SlashCommand = {
name: 'doctor',
get description() {
return t('Run installation and environment diagnostics');
},
kind: CommandKind.BUILT_IN,
action: async (context) => {
const executionMode = context.executionMode ?? 'interactive';
const abortSignal = context.abortSignal;
if (executionMode === 'interactive') {
context.ui.setPendingItem({
type: 'info',
text: t('Running diagnostics...'),
});
}
try {
const checks = await runDoctorChecks(context);
if (abortSignal?.aborted) {
return;
}
const summary = {
pass: checks.filter((c) => c.status === 'pass').length,
warn: checks.filter((c) => c.status === 'warn').length,
fail: checks.filter((c) => c.status === 'fail').length,
};
if (executionMode === 'interactive') {
const doctorItem: Omit<HistoryItemDoctor, 'id'> = {
type: 'doctor',
checks,
summary,
};
context.ui.addItem(doctorItem, Date.now());
return;
}
return {
type: 'message' as const,
messageType: (summary.fail > 0 ? 'error' : 'info') as 'error' | 'info',
content: JSON.stringify({ checks, summary }, null, 2),
};
} finally {
if (executionMode === 'interactive') {
context.ui.setPendingItem(null);
}
}
},
};

View file

@ -45,6 +45,7 @@ import { SkillsList } from './views/SkillsList.js';
import { ToolsList } from './views/ToolsList.js';
import { McpStatus } from './views/McpStatus.js';
import { ContextUsage } from './views/ContextUsage.js';
import { DoctorReport } from './views/DoctorReport.js';
import { ArenaAgentCard, ArenaSessionCard } from './arena/ArenaCards.js';
import { InsightProgressMessage } from './messages/InsightProgressMessage.js';
import { BtwMessage } from './messages/BtwMessage.js';
@ -232,6 +233,13 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
showDetails={itemForDisplay.showDetails}
/>
)}
{itemForDisplay.type === 'doctor' && (
<DoctorReport
checks={itemForDisplay.checks}
summary={itemForDisplay.summary}
width={boxWidth}
/>
)}
{itemForDisplay.type === 'arena_agent_complete' && (
<ArenaAgentCard agent={itemForDisplay.agent} width={boxWidth} />
)}

View file

@ -0,0 +1,131 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import type { DoctorCheckResult, DoctorCheckStatus } from '../../types.js';
import { t } from '../../../i18n/index.js';
interface DoctorReportProps {
checks: DoctorCheckResult[];
summary: { pass: number; warn: number; fail: number };
width?: number;
}
const STATUS_ICONS: Record<DoctorCheckStatus, string> = {
pass: '\u2713', // checkmark
warn: '\u26A0', // warning triangle
fail: '\u2717', // X mark
};
function getStatusColor(status: DoctorCheckStatus): string {
switch (status) {
case 'pass':
return theme.status.success;
case 'warn':
return theme.status.warning;
case 'fail':
return theme.status.error;
default:
return theme.text.primary;
}
}
/**
* Group checks by category, preserving insertion order.
*/
function groupByCategory(
checks: DoctorCheckResult[],
): Map<string, DoctorCheckResult[]> {
const groups = new Map<string, DoctorCheckResult[]>();
for (const check of checks) {
const group = groups.get(check.category);
if (group) {
group.push(check);
} else {
groups.set(check.category, [check]);
}
}
return groups;
}
export const DoctorReport: React.FC<DoctorReportProps> = ({
checks,
summary,
width,
}) => {
const groups = groupByCategory(checks);
const categoryEntries = Array.from(groups.entries());
// Compute the widest check name so the message column aligns consistently.
const nameColWidth = Math.max(20, ...checks.map((c) => c.name.length + 2));
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
paddingY={1}
paddingX={2}
width={width}
>
<Text bold color={theme.text.accent}>
{t('Doctor Report')}
</Text>
<Box height={1} />
{categoryEntries.map(([category, items], groupIdx) => (
<Box
key={category}
flexDirection="column"
marginTop={groupIdx > 0 ? 1 : 0}
>
<Text bold color={theme.text.link}>
{category}
</Text>
{items.map((check) => (
<Box key={`${category}-${check.name}`} flexDirection="column">
<Box flexDirection="row">
<Text color={getStatusColor(check.status)}>
{' '}
{STATUS_ICONS[check.status]}{' '}
</Text>
<Box width={nameColWidth}>
<Text color={theme.text.primary}>{check.name}</Text>
</Box>
<Text dimColor>{check.message}</Text>
</Box>
{check.detail && (
<Box marginLeft={6}>
<Text dimColor>
{'-> '}
{check.detail}
</Text>
</Box>
)}
</Box>
))}
</Box>
))}
<Box marginTop={1}>
<Text dimColor>{'-- '}</Text>
<Text color={theme.status.success}>
{summary.pass} {t('passed')}
</Text>
<Text dimColor>{', '}</Text>
<Text color={theme.status.warning}>
{summary.warn} {t('warnings')}
</Text>
<Text dimColor>{', '}</Text>
<Text color={theme.status.error}>
{summary.fail} {t('failures')}
</Text>
</Box>
</Box>
);
};

View file

@ -417,6 +417,24 @@ export type HistoryItemStopHookSystemMessage = HistoryItemBase & {
message: string;
};
// --- Doctor diagnostics types ---
export type DoctorCheckStatus = 'pass' | 'warn' | 'fail';
export interface DoctorCheckResult {
category: string;
name: string;
status: DoctorCheckStatus;
message: string;
detail?: string;
}
export type HistoryItemDoctor = HistoryItemBase & {
type: 'doctor';
checks: DoctorCheckResult[];
summary: { pass: number; warn: number; fail: number };
};
// Using Omit<HistoryItem, 'id'> seems to have some issues with typescript's
// type inference e.g. historyItem.type === 'tool_group' isn't auto-inferring that
// 'tools' in historyItem.
@ -456,7 +474,8 @@ export type HistoryItemWithoutId =
| HistoryItemMemorySaved
| HistoryItemUserPromptSubmitBlocked
| HistoryItemStopHookLoop
| HistoryItemStopHookSystemMessage;
| HistoryItemStopHookSystemMessage
| HistoryItemDoctor;
export type HistoryItem = HistoryItemWithoutId & { id: number };

View file

@ -0,0 +1,261 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { runDoctorChecks } from './doctorChecks.js';
import { type CommandContext } from '../ui/commands/types.js';
import { createMockCommandContext } from '../test-utils/mockCommandContext.js';
import * as systemInfoUtils from './systemInfo.js';
import * as authModule from '../config/auth.js';
vi.mock('./systemInfo.js');
vi.mock('../config/auth.js');
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
(await importOriginal()) as typeof import('@qwen-code/qwen-code-core');
return {
...actual,
canUseRipgrep: vi.fn().mockResolvedValue(true),
getMCPServerStatus: vi.fn().mockReturnValue('connected'),
MCPServerStatus: {
CONNECTED: 'connected',
CONNECTING: 'connecting',
DISCONNECTED: 'disconnected',
},
};
});
describe('runDoctorChecks', () => {
let mockContext: CommandContext;
beforeEach(() => {
mockContext = createMockCommandContext({
services: {
config: {
getAuthType: vi.fn().mockReturnValue('openai'),
getGeminiClient: vi.fn().mockReturnValue({
isInitialized: vi.fn().mockReturnValue(true),
}),
getModel: vi.fn().mockReturnValue('gpt-4'),
getMcpServers: vi.fn().mockReturnValue({}),
getToolRegistry: vi.fn().mockReturnValue({
getAllTools: vi.fn().mockReturnValue([{ name: 'tool1' }]),
}),
getUseBuiltinRipgrep: vi.fn().mockReturnValue(false),
},
settings: {
merged: {},
},
git: {} as never,
},
} as unknown as CommandContext);
vi.mocked(systemInfoUtils.getNpmVersion).mockResolvedValue('10.0.0');
vi.mocked(systemInfoUtils.getGitVersion).mockResolvedValue(
'git version 2.39.0',
);
vi.mocked(authModule.validateAuthMethod).mockReturnValue(null);
});
afterEach(() => {
vi.clearAllMocks();
});
it('should return results for all categories', async () => {
const results = await runDoctorChecks(mockContext);
const categories = [...new Set(results.map((r) => r.category))];
expect(categories).toContain('System');
expect(categories).toContain('Authentication');
expect(categories).toContain('Configuration');
expect(categories).toContain('Tools');
expect(categories).toContain('Git');
});
it('should pass Node.js version check for v20+', async () => {
const results = await runDoctorChecks(mockContext);
const nodeCheck = results.find((r) => r.name === 'Node.js version');
expect(nodeCheck).toBeDefined();
expect(nodeCheck!.status).toBe('pass');
});
it('should pass npm check when npm is available', async () => {
const results = await runDoctorChecks(mockContext);
const npmCheck = results.find((r) => r.name === 'npm version');
expect(npmCheck).toBeDefined();
expect(npmCheck!.status).toBe('pass');
expect(npmCheck!.message).toBe('10.0.0');
});
it('should warn when npm is not available', async () => {
vi.mocked(systemInfoUtils.getNpmVersion).mockResolvedValue('unknown');
const results = await runDoctorChecks(mockContext);
const npmCheck = results.find((r) => r.name === 'npm version');
expect(npmCheck!.status).toBe('warn');
});
it('should fail auth check when auth is not configured', async () => {
mockContext = createMockCommandContext({
services: {
config: {
getAuthType: vi.fn().mockReturnValue(undefined),
getGeminiClient: vi.fn().mockReturnValue({
isInitialized: vi.fn().mockReturnValue(false),
}),
getModel: vi.fn().mockReturnValue('gpt-4'),
getMcpServers: vi.fn().mockReturnValue({}),
getToolRegistry: vi.fn().mockReturnValue({
getAllTools: vi.fn().mockReturnValue([]),
}),
getUseBuiltinRipgrep: vi.fn().mockReturnValue(false),
},
settings: { merged: {} },
git: {} as never,
},
} as unknown as CommandContext);
const results = await runDoctorChecks(mockContext);
const authCheck = results.find((r) => r.name === 'API key');
expect(authCheck!.status).toBe('fail');
});
it('should pass auth check when credentials are valid', async () => {
const results = await runDoctorChecks(mockContext);
const authCheck = results.find((r) => r.name === 'API key');
expect(authCheck!.status).toBe('pass');
});
it('should pass tool registry check when registry is loaded', async () => {
const results = await runDoctorChecks(mockContext);
const toolCheck = results.find((r) => r.name === 'Tool registry');
expect(toolCheck!.status).toBe('pass');
expect(toolCheck!.message).toContain('1');
});
it('should pass git check when git service exists', async () => {
const results = await runDoctorChecks(mockContext);
const gitCheck = results.find((r) => r.name === 'Git');
expect(gitCheck!.status).toBe('pass');
});
it('should warn git check when git service is missing and git binary is unavailable', async () => {
mockContext = createMockCommandContext({
services: {
config: {
getAuthType: vi.fn().mockReturnValue('openai'),
getGeminiClient: vi.fn().mockReturnValue({
isInitialized: vi.fn().mockReturnValue(true),
}),
getModel: vi.fn().mockReturnValue('gpt-4'),
getMcpServers: vi.fn().mockReturnValue({}),
getToolRegistry: vi.fn().mockReturnValue({
getAllTools: vi.fn().mockReturnValue([]),
}),
getUseBuiltinRipgrep: vi.fn().mockReturnValue(false),
},
settings: { merged: {} },
git: undefined,
},
} as unknown as CommandContext);
vi.mocked(systemInfoUtils.getGitVersion).mockResolvedValue('unknown');
const results = await runDoctorChecks(mockContext);
const gitCheck = results.find((r) => r.name === 'Git');
expect(gitCheck!.status).toBe('warn');
});
it('should pass git check when git service is missing but git binary is available', async () => {
mockContext = createMockCommandContext({
services: {
config: {
getAuthType: vi.fn().mockReturnValue('openai'),
getGeminiClient: vi.fn().mockReturnValue({
isInitialized: vi.fn().mockReturnValue(true),
}),
getModel: vi.fn().mockReturnValue('gpt-4'),
getMcpServers: vi.fn().mockReturnValue({}),
getToolRegistry: vi.fn().mockReturnValue({
getAllTools: vi.fn().mockReturnValue([]),
}),
getUseBuiltinRipgrep: vi.fn().mockReturnValue(false),
},
settings: { merged: {} },
git: undefined,
},
} as unknown as CommandContext);
vi.mocked(systemInfoUtils.getGitVersion).mockResolvedValue(
'git version 2.39.0',
);
const results = await runDoctorChecks(mockContext);
const gitCheck = results.find((r) => r.name === 'Git');
expect(gitCheck!.status).toBe('pass');
expect(gitCheck!.message).toBe('git version 2.39.0');
});
it('should report disabled MCP servers as pass instead of fail', async () => {
mockContext = createMockCommandContext({
services: {
config: {
getAuthType: vi.fn().mockReturnValue('openai'),
getGeminiClient: vi.fn().mockReturnValue({
isInitialized: vi.fn().mockReturnValue(true),
}),
getModel: vi.fn().mockReturnValue('gpt-4'),
getMcpServers: vi
.fn()
.mockReturnValue({ 'my-server': { command: 'node' } }),
isMcpServerDisabled: vi.fn().mockReturnValue(true),
getToolRegistry: vi.fn().mockReturnValue({
getAllTools: vi.fn().mockReturnValue([]),
}),
getUseBuiltinRipgrep: vi.fn().mockReturnValue(false),
},
settings: { merged: {} },
git: {} as never,
},
} as unknown as CommandContext);
const results = await runDoctorChecks(mockContext);
const mcpCheck = results.find((r) => r.name === 'my-server');
expect(mcpCheck).toBeDefined();
expect(mcpCheck!.status).toBe('pass');
expect(mcpCheck!.message).toBe('disabled');
});
it('should not report MCP connection status in non-interactive mode', async () => {
mockContext = createMockCommandContext({
executionMode: 'non_interactive',
services: {
config: {
getAuthType: vi.fn().mockReturnValue('openai'),
getGeminiClient: vi.fn().mockReturnValue({
isInitialized: vi.fn().mockReturnValue(true),
}),
getModel: vi.fn().mockReturnValue('gpt-4'),
getMcpServers: vi
.fn()
.mockReturnValue({ 'my-server': { command: 'node' } }),
isMcpServerDisabled: vi.fn().mockReturnValue(false),
getToolRegistry: vi.fn().mockReturnValue({
getAllTools: vi.fn().mockReturnValue([]),
}),
getUseBuiltinRipgrep: vi.fn().mockReturnValue(false),
},
settings: { merged: {} },
git: {} as never,
},
} as unknown as CommandContext);
const results = await runDoctorChecks(mockContext);
const mcpCheck = results.find((r) => r.name === 'my-server');
expect(mcpCheck).toBeDefined();
// In non-interactive mode, servers are never connected — must not report as fail
expect(mcpCheck!.status).toBe('pass');
});
});

View file

@ -0,0 +1,374 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import process from 'node:process';
import os from 'node:os';
import { getNpmVersion, getGitVersion } from './systemInfo.js';
import { validateAuthMethod } from '../config/auth.js';
import type { CommandContext } from '../ui/commands/types.js';
import type { DoctorCheckResult } from '../ui/types.js';
import {
canUseRipgrep,
getMCPServerStatus,
MCPServerStatus,
} from '@qwen-code/qwen-code-core';
import { t } from '../i18n/index.js';
const MIN_NODE_MAJOR = 20;
function checkNodeVersion(): DoctorCheckResult {
const version = process.version;
const major = parseInt(version.replace(/^v/, '').split('.')[0]!, 10);
if (isNaN(major) || major < MIN_NODE_MAJOR) {
return {
category: t('System'),
name: t('Node.js version'),
status: 'fail',
message: version,
detail: t('Node.js v{{min}}+ is required. Current: {{version}}', {
min: String(MIN_NODE_MAJOR),
version,
}),
};
}
return {
category: t('System'),
name: t('Node.js version'),
status: 'pass',
message: version,
};
}
async function checkNpmVersion(): Promise<DoctorCheckResult> {
const version = await getNpmVersion();
if (version === 'unknown') {
return {
category: t('System'),
name: t('npm version'),
status: 'warn',
message: t('not found'),
detail: t('npm is not available. Some features may not work.'),
};
}
return {
category: t('System'),
name: t('npm version'),
status: 'pass',
message: version,
};
}
function checkPlatform(): DoctorCheckResult {
return {
category: t('System'),
name: t('Platform'),
status: 'pass',
message: `${process.platform}/${process.arch} (${os.release()})`,
};
}
function checkAuth(context: CommandContext): DoctorCheckResult {
const authType = context.services.config?.getAuthType();
if (!authType) {
return {
category: t('Authentication'),
name: t('API key'),
status: 'fail',
message: t('not configured'),
detail: t('Run /auth to configure authentication.'),
};
}
const error = validateAuthMethod(
authType,
context.services.config ?? undefined,
);
if (error) {
return {
category: t('Authentication'),
name: t('API key'),
status: 'fail',
message: t('invalid ({{authType}})', { authType }),
detail: error,
};
}
return {
category: t('Authentication'),
name: t('API key'),
status: 'pass',
message: t('configured ({{authType}})', { authType }),
};
}
async function checkApiClient(
context: CommandContext,
): Promise<DoctorCheckResult> {
const config = context.services.config;
if (!config) {
return {
category: t('Authentication'),
name: t('API client'),
status: 'fail',
message: t('config not loaded'),
};
}
try {
const client = config.getGeminiClient();
if (client.isInitialized()) {
return {
category: t('Authentication'),
name: t('API client'),
status: 'pass',
message: t('client initialized'),
};
}
return {
category: t('Authentication'),
name: t('API client'),
status: 'warn',
message: t('client not initialized'),
detail: t('The API client has not been initialized yet.'),
};
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
return {
category: t('Authentication'),
name: t('API client'),
status: 'warn',
message: t('error'),
detail: errorMsg,
};
}
}
function checkSettings(context: CommandContext): DoctorCheckResult {
const settings = context.services.settings;
if (!settings) {
return {
category: t('Configuration'),
name: t('Settings'),
status: 'fail',
message: t('not loaded'),
detail: t(
'Settings could not be loaded. Check your settings files for syntax errors.',
),
};
}
return {
category: t('Configuration'),
name: t('Settings'),
status: 'pass',
message: t('loaded'),
};
}
function checkModel(context: CommandContext): DoctorCheckResult {
const model = context.services.config?.getModel();
if (!model) {
return {
category: t('Configuration'),
name: t('Model'),
status: 'fail',
message: t('not configured'),
detail: t('Run /model to select a model.'),
};
}
return {
category: t('Configuration'),
name: t('Model'),
status: 'pass',
message: model,
};
}
function checkMcpServers(context: CommandContext): DoctorCheckResult[] {
const config = context.services.config;
const servers = config?.getMcpServers();
if (!servers || Object.keys(servers).length === 0) {
return [
{
category: t('MCP Servers'),
name: t('MCP servers'),
status: 'pass',
message: t('none configured'),
},
];
}
// In non-interactive mode MCP connections are never established, so querying
// getMCPServerStatus would always return DISCONNECTED and produce false failures.
// Report configured servers as unchecked instead.
if (context.executionMode !== 'interactive') {
return Object.keys(servers).map((name) => ({
category: t('MCP Servers'),
name,
status: 'pass' as const,
message: config?.isMcpServerDisabled(name)
? t('disabled')
: t('configured (not checked in non-interactive mode)'),
}));
}
return Object.keys(servers).map((name) => {
// Skip disabled servers — report as informational pass
if (config?.isMcpServerDisabled(name)) {
return {
category: t('MCP Servers'),
name,
status: 'pass' as const,
message: t('disabled'),
};
}
const status = getMCPServerStatus(name);
switch (status) {
case MCPServerStatus.CONNECTED:
return {
category: t('MCP Servers'),
name,
status: 'pass' as const,
message: t('connected'),
};
case MCPServerStatus.CONNECTING:
return {
category: t('MCP Servers'),
name,
status: 'warn' as const,
message: t('connecting'),
detail: t('Server is still starting up.'),
};
case MCPServerStatus.DISCONNECTED:
default:
return {
category: t('MCP Servers'),
name,
status: 'fail' as const,
message: t('disconnected'),
detail: t(
'Check that the server process is running and configuration is correct.',
),
};
}
});
}
function checkToolRegistry(context: CommandContext): DoctorCheckResult {
const registry = context.services.config?.getToolRegistry();
if (!registry) {
return {
category: t('Tools'),
name: t('Tool registry'),
status: 'fail',
message: t('not loaded'),
};
}
const count = registry.getAllTools().length;
return {
category: t('Tools'),
name: t('Tool registry'),
status: 'pass',
message: t('{{count}} tools registered', { count: String(count) }),
};
}
async function checkRipgrep(
context: CommandContext,
): Promise<DoctorCheckResult> {
try {
const useBuiltin = context.services.config?.getUseBuiltinRipgrep() ?? false;
const result = await canUseRipgrep(useBuiltin);
if (result) {
return {
category: t('Tools'),
name: t('Ripgrep'),
status: 'pass',
message: t('available'),
};
}
return {
category: t('Tools'),
name: t('Ripgrep'),
status: 'warn',
message: t('not available'),
detail: t(
'Install ripgrep for faster file search: https://github.com/BurntSushi/ripgrep',
),
};
} catch {
return {
category: t('Tools'),
name: t('Ripgrep'),
status: 'warn',
message: t('check failed'),
};
}
}
async function checkGit(context: CommandContext): Promise<DoctorCheckResult> {
if (context.services.git) {
return {
category: t('Git'),
name: t('Git'),
status: 'pass',
message: t('available'),
};
}
// services.git is undefined in non-interactive mode — probe the binary directly
const version = await getGitVersion();
if (version === 'unknown') {
return {
category: t('Git'),
name: t('Git'),
status: 'warn',
message: t('not available'),
detail: t('Git features will be limited.'),
};
}
return {
category: t('Git'),
name: t('Git'),
status: 'pass',
message: version,
};
}
/**
* Run all doctor diagnostic checks.
*/
export async function runDoctorChecks(
context: CommandContext,
): Promise<DoctorCheckResult[]> {
// Run async checks in parallel
const [npmResult, ripgrepResult, apiClientResult, gitResult] =
await Promise.all([
checkNpmVersion(),
checkRipgrep(context),
checkApiClient(context),
checkGit(context),
]);
return [
// System
checkNodeVersion(),
npmResult,
checkPlatform(),
// Authentication
checkAuth(context),
apiClientResult,
// Configuration
checkSettings(context),
checkModel(context),
// MCP Servers
...checkMcpServers(context),
// Tools
checkToolRegistry(context),
ripgrepResult,
// Git
gitResult,
];
}

View file

@ -56,6 +56,18 @@ export async function getNpmVersion(): Promise<string> {
}
}
/**
* Gets the Git version, handling cases where git might not be available.
* Returns 'unknown' if git command fails or is not found.
*/
export async function getGitVersion(): Promise<string> {
try {
return execSync('git --version', { encoding: 'utf-8' }).trim();
} catch {
return 'unknown';
}
}
/**
* Gets the IDE client name if IDE mode is enabled.
* Returns empty string if IDE mode is disabled or IDE client is not detected.