Skip to content
Open
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
58 changes: 58 additions & 0 deletions cli/src/commands/__tests__/doctor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, test, mock, afterEach } from 'bun:test'

import { collectDoctorReport, formatDoctorReport } from '../doctor'

describe('/doctor command', () => {
test('collectDoctorReport returns a valid report', async () => {
const report = await collectDoctorReport()

expect(report).toBeDefined()
expect(report.product).toBeDefined()
expect(report.results).toBeInstanceOf(Array)
expect(report.results.length).toBeGreaterThan(0)
expect(report.summary).toBeDefined()
expect(report.summary.ok).toBeDefined()
expect(report.summary.warnings).toBeDefined()
expect(report.summary.errors).toBeDefined()
})

test('collectDoctorReport includes platform check', async () => {
const report = await collectDoctorReport()

const platformCheck = report.results.find((r) => r.name === 'platform')
expect(platformCheck).toBeDefined()
expect(platformCheck!.status).toBe('ok')
})

test('collectDoctorReport includes runtime check', async () => {
const report = await collectDoctorReport()

const runtimeCheck = report.results.find((r) => r.name === 'runtime')
expect(runtimeCheck).toBeDefined()
expect(runtimeCheck!.status).toBe('ok')
})

test('formatDoctorReport returns a string', async () => {
const report = await collectDoctorReport()
const formatted = formatDoctorReport(report)

expect(typeof formatted).toBe('string')
expect(formatted.length).toBeGreaterThan(0)
})

test('formatDoctorReport includes product name', async () => {
const report = await collectDoctorReport()
const formatted = formatDoctorReport(report)

expect(formatted).toContain(report.product)
expect(formatted).toContain('doctor')
})

test('formatDoctorReport includes summary icon', async () => {
const report = await collectDoctorReport()
const formatted = formatDoctorReport(report)

// Should contain either ✅, ⚠️, or ❌
expect(formatted).toMatch(/[✅⚠️❌]/)
})
})
15 changes: 15 additions & 0 deletions cli/src/commands/command-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ import {
collectProcessDiagnostics,
formatProcessDiagnostics,
} from './process-diagnostics'
import {
collectDoctorReport,
formatDoctorReport,
} from './doctor'
import { buildInterviewPrompt, buildPlanPrompt, buildReviewPromptFromArgs } from './prompt-builders'
import { handleReasoningCommand } from './reasoning'
import { runBashCommand } from './router'
Expand Down Expand Up @@ -230,6 +234,17 @@ const ALL_COMMANDS: CommandDefinition[] = [
clearInput(params)
},
}),
defineCommand({
name: 'doctor',
aliases: ['check', 'health'],
handler: async (params) => {
const report = await collectDoctorReport()
const formatted = formatDoctorReport(report)
params.setMessages((prev) => [...prev, getSystemMessage(formatted)])
params.saveToHistory(params.inputValue.trim())
clearInput(params)
},
}),
defineCommand({
name: 'copy',
aliases: ['copy-chat', 'export'],
Expand Down
282 changes: 282 additions & 0 deletions cli/src/commands/doctor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
import { execSync } from 'child_process'
import { existsSync } from 'fs'
import { join } from 'path'

import { IS_FREEBUFF } from '../utils/constants'
import { getWebsiteUrl } from '@codebuff/sdk'

export type DiagnosticResult = {
name: string
status: 'ok' | 'warning' | 'error'
message: string
details?: string
}

export type DoctorReport = {
product: string
results: DiagnosticResult[]
summary: {
ok: number
warnings: number
errors: number
}
}

/**
* Check if a command is available in PATH
*/
function isCommandAvailable(command: string): boolean {
try {
const which = process.platform === 'win32' ? 'where' : 'which'
execSync(`${which} ${command}`, { stdio: 'ignore', timeout: 5000 })
return true
} catch {
return false
}
}

/**
* Check if ripgrep (rg) is available and executable
*/
function checkRipgrep(): DiagnosticResult {
const rgAvailable = isCommandAvailable('rg')

if (!rgAvailable) {
return {
name: 'ripgrep (rg)',
status: 'error',
message: 'ripgrep not found in PATH',
details: 'Code search requires ripgrep. Install it:\n - macOS: brew install ripgrep\n - Ubuntu/Debian: sudo apt install ripgrep\n - Windows: scoop install ripgrep',
}
}

return {
name: 'ripgrep (rg)',
status: 'ok',
message: 'Available',
}
}

/**
* Check git availability and repository status
*/
function checkGit(): DiagnosticResult[] {
const results: DiagnosticResult[] = []

// Check git availability
const gitAvailable = isCommandAvailable('git')
if (!gitAvailable) {
results.push({
name: 'git',
status: 'error',
message: 'git not found in PATH',
details: 'Git is required for version control features. Install it:\n - macOS: xcode-select --install\n - Ubuntu/Debian: sudo apt install git\n - Windows: https://git-scm.com/download/win',
})
return results
}

results.push({
name: 'git',
status: 'ok',
message: 'Available',
})

// Check if we're in a git repository
try {
execSync('git rev-parse --is-inside-work-tree', {
stdio: 'ignore',
timeout: 5000,
cwd: process.cwd()
})
results.push({
name: 'git repository',
status: 'ok',
message: 'Current directory is a git repository',
})
} catch {
results.push({
name: 'git repository',
status: 'warning',
message: 'Current directory is not a git repository',
details: 'Some features may not work correctly outside a git repository.',
})
}

return results
}

/**
* Check API connectivity
*/
async function checkConnectivity(): Promise<DiagnosticResult> {
const websiteUrl = getWebsiteUrl()

try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 5000)

const response = await fetch(`${websiteUrl}/api/healthz`, {
method: 'GET',
signal: controller.signal,
})

clearTimeout(timeoutId)

if (response.ok) {
return {
name: 'API connectivity',
status: 'ok',
message: `Connected to ${websiteUrl}`,
}
} else {
return {
name: 'API connectivity',
status: 'warning',
message: `API responded with status ${response.status}`,
details: 'The API is reachable but returned an error. This may indicate a temporary issue.',
}
}
} catch (error) {
const isTimeout = error instanceof Error && error.name === 'AbortError'
return {
name: 'API connectivity',
status: 'error',
message: isTimeout ? 'Connection timed out' : 'Failed to connect',
details: `Could not reach ${websiteUrl}. Check your internet connection and firewall settings.`,
}
}
}

/**
* Check if tmux is available
*/
function checkTmux(): DiagnosticResult {
const tmuxAvailable = isCommandAvailable('tmux')

if (!tmuxAvailable) {
return {
name: 'tmux',
status: 'warning',
message: 'tmux not found',
details: 'tmux is optional but recommended for multiplexing and detached sessions. Install it:\n - macOS: brew install tmux\n - Ubuntu/Debian: sudo apt install tmux\n - Windows: Available through WSL',
}
}

return {
name: 'tmux',
status: 'ok',
message: 'Available',
}
}

/**
* Check Node.js/Bun runtime
*/
function checkRuntime(): DiagnosticResult {
if (typeof Bun !== 'undefined') {
return {
name: 'runtime',
status: 'ok',
message: `Bun ${Bun.version}`,
}
}

return {
name: 'runtime',
status: 'ok',
message: `Node.js ${process.version}`,
}
}

/**
* Check platform and architecture
*/
function checkPlatform(): DiagnosticResult {
return {
name: 'platform',
status: 'ok',
message: `${process.platform} ${process.arch}`,
}
}

/**
* Collect all diagnostic information
*/
export async function collectDoctorReport(): Promise<DoctorReport> {
const results: DiagnosticResult[] = []

// Add basic info
results.push(checkPlatform())
results.push(checkRuntime())

// Check dependencies
results.push(checkRipgrep())
results.push(...checkGit())
results.push(checkTmux())

// Check connectivity (async)
const connectivity = await checkConnectivity()
results.push(connectivity)

// Calculate summary
const summary = {
ok: results.filter((r) => r.status === 'ok').length,
warnings: results.filter((r) => r.status === 'warning').length,
errors: results.filter((r) => r.status === 'error').length,
}

return {
product: IS_FREEBUFF ? 'Freebuff' : 'Codebuff',
results,
summary,
}
}

/**
* Format the doctor report for display
*/
export function formatDoctorReport(report: DoctorReport): string {
const lines: string[] = []

lines.push(`### ${report.product} doctor`)
lines.push('')

// Summary
const { ok, warnings, errors } = report.summary
if (errors === 0 && warnings === 0) {
lines.push('✅ All checks passed!')
} else if (errors === 0) {
lines.push(`⚠️ ${warnings} warning(s) found`)
} else {
lines.push(`❌ ${errors} error(s), ${warnings} warning(s) found`)
}
lines.push('')

// Detailed results
for (const result of report.results) {
const icon = result.status === 'ok' ? '✅' : result.status === 'warning' ? '⚠️' : '❌'
lines.push(`${icon} **${result.name}**: ${result.message}`)
if (result.details) {
// Indent details
const detailLines = result.details.split('\n')
for (const line of detailLines) {
lines.push(` ${line}`)
}
}
}

lines.push('')

// Recommendations
if (errors > 0) {
lines.push('### Recommendations')
lines.push('')
lines.push('Fix the errors above to ensure full functionality.')
} else if (warnings > 0) {
lines.push('### Notes')
lines.push('')
lines.push('The warnings above are optional but may improve your experience.')
}

return lines.join('\n')
}
6 changes: 6 additions & 0 deletions cli/src/data/slash-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ const ALL_SLASH_COMMANDS: SlashCommand[] = [
description: 'Show local CLI resource usage and terminal tool process IDs',
aliases: ['diag', 'processes'],
},
{
id: 'doctor',
label: 'doctor',
description: 'Diagnose local environment and dependencies',
aliases: ['check', 'health'],
},
{
id: 'ads:enable',
label: 'ads:enable',
Expand Down
Loading