Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/cli/src/nonInteractiveCliCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -46,6 +47,7 @@ export const ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE = [
'btw',
'bug',
'context',
'doctor',
Comment thread
doudouOUC marked this conversation as resolved.
] as const;

/**
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/services/BuiltinCommandLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -96,6 +97,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
contextCommand,
copyCommand,
docsCommand,
doctorCommand,
directoryCommand,
editorCommand,
exportCommand,
Expand Down
142 changes: 142 additions & 0 deletions packages/cli/src/ui/commands/doctorCommand.test.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 64 additions & 0 deletions packages/cli/src/ui/commands/doctorCommand.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
},
};
8 changes: 8 additions & 0 deletions packages/cli/src/ui/components/HistoryItemDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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} />
)}
Expand Down
128 changes: 128 additions & 0 deletions packages/cli/src/ui/components/views/DoctorReport.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* @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());

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={20}>
<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>
);
};
Loading
Loading