diff --git a/packages/cli/src/nonInteractiveCliCommands.ts b/packages/cli/src/nonInteractiveCliCommands.ts index f97875911..889f19247 100644 --- a/packages/cli/src/nonInteractiveCliCommands.ts +++ b/packages/cli/src/nonInteractiveCliCommands.ts @@ -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; /** diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 764928ea5..73c944c64 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -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, diff --git a/packages/cli/src/ui/commands/doctorCommand.test.ts b/packages/cli/src/ui/commands/doctorCommand.test.ts new file mode 100644 index 000000000..2e3e79892 --- /dev/null +++ b/packages/cli/src/ui/commands/doctorCommand.test.ts @@ -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); + }); +}); diff --git a/packages/cli/src/ui/commands/doctorCommand.ts b/packages/cli/src/ui/commands/doctorCommand.ts new file mode 100644 index 000000000..d44d9ed5c --- /dev/null +++ b/packages/cli/src/ui/commands/doctorCommand.ts @@ -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 = { + 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); + } + } + }, +}; diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 56f128839..f9ea0afba 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -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 = ({ showDetails={itemForDisplay.showDetails} /> )} + {itemForDisplay.type === 'doctor' && ( + + )} {itemForDisplay.type === 'arena_agent_complete' && ( )} diff --git a/packages/cli/src/ui/components/views/DoctorReport.tsx b/packages/cli/src/ui/components/views/DoctorReport.tsx new file mode 100644 index 000000000..7102d73da --- /dev/null +++ b/packages/cli/src/ui/components/views/DoctorReport.tsx @@ -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 = { + 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 { + const groups = new Map(); + 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 = ({ + 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 ( + + + {t('Doctor Report')} + + + + {categoryEntries.map(([category, items], groupIdx) => ( + 0 ? 1 : 0} + > + + {category} + + {items.map((check) => ( + + + + {' '} + {STATUS_ICONS[check.status]}{' '} + + + {check.name} + + {check.message} + + {check.detail && ( + + + {'-> '} + {check.detail} + + + )} + + ))} + + ))} + + + {'-- '} + + {summary.pass} {t('passed')} + + {', '} + + {summary.warn} {t('warnings')} + + {', '} + + {summary.fail} {t('failures')} + + + + ); +}; diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 364a3b575..8c4fb355b 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -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 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 }; diff --git a/packages/cli/src/utils/doctorChecks.test.ts b/packages/cli/src/utils/doctorChecks.test.ts new file mode 100644 index 000000000..262fa63fc --- /dev/null +++ b/packages/cli/src/utils/doctorChecks.test.ts @@ -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'); + }); +}); diff --git a/packages/cli/src/utils/doctorChecks.ts b/packages/cli/src/utils/doctorChecks.ts new file mode 100644 index 000000000..6422bb5c1 --- /dev/null +++ b/packages/cli/src/utils/doctorChecks.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + // 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, + ]; +} diff --git a/packages/cli/src/utils/systemInfo.ts b/packages/cli/src/utils/systemInfo.ts index 856da53d7..ec0f9e25c 100644 --- a/packages/cli/src/utils/systemInfo.ts +++ b/packages/cli/src/utils/systemInfo.ts @@ -56,6 +56,18 @@ export async function getNpmVersion(): Promise { } } +/** + * 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 { + 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.