Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
17 changes: 10 additions & 7 deletions src/agents/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,16 @@ export class AgentRegistry {

static async getInstalledAgents(): Promise<AgentAdapter[]> {
AgentRegistry.initialize();
const agents: AgentAdapter[] = [];
for (const adapter of AgentRegistry.adapters.values()) {
if (await adapter.isInstalled()) {
agents.push(adapter);
}
}
return agents;
const allAdapters = Array.from(AgentRegistry.adapters.values());
const installResults = await Promise.all(
allAdapters.map(async (adapter) => ({
adapter,
installed: await adapter.isInstalled()
}))
);
return installResults
.filter(({ installed }) => installed)
.map(({ adapter }) => adapter);
}

/**
Expand Down
15 changes: 10 additions & 5 deletions src/cli/commands/doctor/checks/AgentsCheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,17 @@ export class AgentsCheck implements ItemWiseHealthCheck {
const installedAgents = await AgentRegistry.getInstalledAgents();

if (installedAgents.length > 0) {
for (const agent of installedAgents) {
const version = await agent.getVersion();
const versionStr = version ? ` (${version})` : '';
// Parallelize version + installation method checks across all agents
const agentResults = await Promise.all(
installedAgents.map(async (agent) => {
const version = await agent.getVersion();
const versionStr = version ? ` (${version})` : '';
const deprecationWarning = await this.checkDeprecatedInstallation(agent, versionStr);
return { agent, versionStr, deprecationWarning };
})
);

// Check for deprecated npm installation
const deprecationWarning = await this.checkDeprecatedInstallation(agent, versionStr);
for (const { agent, versionStr, deprecationWarning } of agentResults) {
if (deprecationWarning) {
details.push(deprecationWarning);
continue;
Expand Down
14 changes: 10 additions & 4 deletions src/cli/commands/doctor/checks/FrameworksCheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,16 @@ export class FrameworksCheck implements ItemWiseHealthCheck {
message: 'No frameworks registered'
});
} else {
// Check each framework
for (const framework of frameworks) {
const installed = await framework.isInstalled();
const version = installed ? await framework.getVersion() : null;
// Check all frameworks in parallel
const frameworkResults = await Promise.all(
frameworks.map(async (framework) => {
const installed = await framework.isInstalled();
const version = installed ? await framework.getVersion() : null;
return { framework, installed, version };
})
);

for (const { framework, installed, version } of frameworkResults) {
const versionStr = version ? ` (${version})` : '';

if (installed) {
Expand Down
206 changes: 99 additions & 107 deletions src/cli/commands/doctor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import { Command } from 'commander';
import chalk from 'chalk';
import os from 'os';
import { HealthCheck, ItemWiseHealthCheck, HealthCheckResult } from './types.js';
import { HealthCheckResult } from './types.js';
import { HealthCheckFormatter } from './formatter.js';
import {
NodeVersionCheck,
Expand Down Expand Up @@ -79,120 +79,112 @@ export function createDoctorCommand(): Command {
// Display header
formatter.displayHeader();

// Define standard health checks
const checks: HealthCheck[] = [
new NodeVersionCheck(),
new NpmCheck(),
new PythonCheck(),
new UvCheck(),
new AwsCliCheck(),
new AIConfigCheck(),
new AgentsCheck(),
new WorkflowsCheck(),
new FrameworksCheck()
];

// Run and display standard checks immediately
for (const check of checks) {
logger.debug(`=== Running Check: ${check.name} ===`);
const startTime = Date.now();

// Check if this is an ItemWiseHealthCheck
const isItemWise = 'runWithItemDisplay' in check;

if (isItemWise) {
// Display section header
console.log(formatter['getCheckHeader'](check.name));

// Run with item-by-item display
const result = await (check as ItemWiseHealthCheck).runWithItemDisplay(
(itemName) => {
logger.debug(` Checking item: ${itemName}`);
formatter.startItem(itemName);
},
(detail) => {
logger.debug(` Result: ${detail.status} - ${detail.message}`);
formatter.displayItem(detail);
}
);
results.push(result);

const elapsed = Date.now() - startTime;
logger.debug(`Check completed in ${elapsed}ms: ${result.success ? 'SUCCESS' : 'FAILED'}`);
logger.debug('');

// Add blank line after section
console.log();
} else {
// Regular check with section-level progress
formatter.startCheck(check.name);
const result = await check.run((message) => {
logger.debug(` Progress: ${message}`);
formatter.updateProgress(message);
// Helper to display a pre-computed check result
const displayResult = (result: HealthCheckResult): void => {
formatter.displayCheck(result);
if (result.details && result.details.length > 0) {
result.details.forEach(detail => {
logger.debug(` - ${detail.status}: ${detail.message}`);
});
results.push(result);
formatter.displayCheck(result);

const elapsed = Date.now() - startTime;
logger.debug(`Check completed in ${elapsed}ms: ${result.success ? 'SUCCESS' : 'FAILED'}`);
if (result.details && result.details.length > 0) {
result.details.forEach(detail => {
logger.debug(` - ${detail.status}: ${detail.message}`);
});
}
logger.debug('');
}
logger.debug('');
};

// --- Group 1: Independent tool checks (run in parallel) ---
const nodeCheck = new NodeVersionCheck();
const npmCheck = new NpmCheck();
const pythonCheck = new PythonCheck();
const uvCheck = new UvCheck();
const awsCheck = new AwsCliCheck();

logger.debug('=== Running Tool Checks (parallel) ===');
const toolStartTime = Date.now();
const [nodeResult, npmResult, pythonResult, uvResult, awsResult] = await Promise.all([
nodeCheck.run(),
npmCheck.run(),
pythonCheck.run(),
uvCheck.run(),
awsCheck.run()
]);
logger.debug(`Tool checks completed in ${Date.now() - toolStartTime}ms`);

// Display tool check results sequentially
for (const result of [nodeResult, npmResult, pythonResult, uvResult, awsResult]) {
results.push(result);
displayResult(result);
}

// After AIConfigCheck, immediately run provider-specific checks
if (check instanceof AIConfigCheck) {
const config = check.getConfig();

if (config && config.provider) {
logger.debug(`=== Running Provider Check: ${config.provider} ===`);
logger.debug(`Base URL: ${config.baseUrl}`);
logger.debug(`Model: ${config.model}`);

// Get health check from ProviderRegistry
const healthCheck = ProviderRegistry.getHealthCheck(config.provider);

if (healthCheck) {
formatter.startCheck('Provider');

try {
const providerStartTime = Date.now();
const providerResult = await healthCheck.check(config);
const elapsed = Date.now() - providerStartTime;

logger.debug(`Provider check completed in ${elapsed}ms`);
logger.debug(`Status: ${providerResult.status}`);

const doctorResult = adaptProviderResult(providerResult);
results.push(doctorResult);
formatter.displayCheckWithHeader(doctorResult);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Provider check failed: ${errorMessage}`);
if (error instanceof Error && error.stack) {
logger.debug(`Stack trace: ${error.stack}`);
}

// If check throws, capture error
results.push({
name: 'Provider Check Error',
success: false,
details: [{
status: 'error',
message: `Check failed: ${errorMessage}`
}]
});
}
} else {
logger.debug(`No health check available for provider: ${config.provider}`);
// --- Group 2: AI Config + Provider check (sequential, provider depends on config) ---
const aiConfigCheck = new AIConfigCheck();
logger.debug('=== Running Check: Active Profile ===');
const configStartTime = Date.now();
const configResult = await aiConfigCheck.run();
logger.debug(`Check completed in ${Date.now() - configStartTime}ms`);
results.push(configResult);
displayResult(configResult);

// Run provider-specific checks if config is available
const config = aiConfigCheck.getConfig();
if (config && config.provider) {
logger.debug(`=== Running Provider Check: ${config.provider} ===`);
logger.debug(`Base URL: ${config.baseUrl}`);
logger.debug(`Model: ${config.model}`);

const healthCheck = ProviderRegistry.getHealthCheck(config.provider);

if (healthCheck) {
formatter.startCheck('Provider');

try {
const providerStartTime = Date.now();
const providerResult = await healthCheck.check(config);
logger.debug(`Provider check completed in ${Date.now() - providerStartTime}ms`);
logger.debug(`Status: ${providerResult.status}`);

const doctorResult = adaptProviderResult(providerResult);
results.push(doctorResult);
formatter.displayCheckWithHeader(doctorResult);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Provider check failed: ${errorMessage}`);
if (error instanceof Error && error.stack) {
logger.debug(`Stack trace: ${error.stack}`);
}

results.push({
name: 'Provider Check Error',
success: false,
details: [{
status: 'error',
message: `Check failed: ${errorMessage}`
}]
});
}
} else {
logger.debug(`No health check available for provider: ${config.provider}`);
}
}

// --- Group 3: Discovery checks (run in parallel) ---
const agentsCheck = new AgentsCheck();
const workflowsCheck = new WorkflowsCheck();
const frameworksCheck = new FrameworksCheck();

logger.debug('=== Running Discovery Checks (parallel) ===');
const discoveryStartTime = Date.now();
const [agentsResult, workflowsResult, frameworksResult] = await Promise.all([
agentsCheck.run(),
workflowsCheck.run(),
frameworksCheck.run()
]);
logger.debug(`Discovery checks completed in ${Date.now() - discoveryStartTime}ms`);

// Display discovery check results sequentially
for (const result of [agentsResult, workflowsResult, frameworksResult]) {
results.push(result);
displayResult(result);
}

logger.debug('=== All Checks Completed ===');
const successCount = results.filter(r => r.success).length;
logger.debug(`Passed: ${successCount}/${results.length}`);
Expand Down
43 changes: 43 additions & 0 deletions src/utils/native-installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,37 @@ async function verifyInstallation(
return null;
}

/**
* Detect if installer output contains HTML instead of expected script output
* This occurs when the installer URL returns an HTML page (e.g., region block,
* maintenance page) instead of the actual installer script. Bash then fails
* trying to execute HTML as shell commands.
*
* @param output - Combined stdout/stderr from the installer process
* @returns true if the output contains HTML markers
*/
function isHtmlInstallerResponse(output: string): boolean {
return /<!DOCTYPE\s+html/i.test(output) || /<html[\s>]/i.test(output);
}

/**
* Extract a user-friendly error message from HTML installer response
* Checks for known error patterns (region block, service unavailability)
* and returns an appropriate message.
*
* @param output - Combined stdout/stderr containing HTML content
* @returns User-friendly error message
*/
function detectHtmlErrorMessage(output: string): string {
// Check for region-specific unavailability
if (/unavailable.*region|not.*available.*here|app.unavailable/i.test(output)) {
return 'Claude Code is not available in your current region. Visit https://claude.ai for information about supported regions.';
}

// Generic HTML response (maintenance page, unexpected redirect, etc.)
return 'Installer URL returned an HTML page instead of an installation script. The service may be temporarily unavailable or not accessible from your location. Visit https://claude.ai for more information.';
}

/**
* Install agent using native platform installer
* Detects platform and executes appropriate installation script
Expand Down Expand Up @@ -246,6 +277,18 @@ export async function installNativeAgent(

// Check if installation succeeded
if (result.code !== 0) {
const combinedOutput = `${result.stderr || ''} ${result.stdout || ''}`;

// Detect HTML response instead of installer script
// This happens when the service returns an error page (e.g., region block)
// instead of the actual installer script, and bash fails trying to parse HTML
if (isHtmlInstallerResponse(combinedOutput)) {
throw new AgentInstallationError(
agentName,
detectHtmlErrorMessage(combinedOutput)
);
}

// SECURITY: Sanitize output before including in error message
// Installer scripts might echo sensitive environment variables
const sanitizedOutput = sanitizeValue(result.stderr || result.stdout);
Expand Down