mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-01 21:20:44 +00:00
parent
c175fd3d4a
commit
9de33dded3
10 changed files with 1016 additions and 1 deletions
|
|
@ -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;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
142
packages/cli/src/ui/commands/doctorCommand.test.ts
Normal file
142
packages/cli/src/ui/commands/doctorCommand.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
64
packages/cli/src/ui/commands/doctorCommand.ts
Normal file
64
packages/cli/src/ui/commands/doctorCommand.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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} />
|
||||
)}
|
||||
|
|
|
|||
131
packages/cli/src/ui/components/views/DoctorReport.tsx
Normal file
131
packages/cli/src/ui/components/views/DoctorReport.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
||||
|
|
|
|||
261
packages/cli/src/utils/doctorChecks.test.ts
Normal file
261
packages/cli/src/utils/doctorChecks.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
374
packages/cli/src/utils/doctorChecks.ts
Normal file
374
packages/cli/src/utils/doctorChecks.ts
Normal 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,
|
||||
];
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue