import { buildEnrichedEnv } from '@main/utils/cliEnv'; import { getShellPreferredHome } from '@main/utils/shellEnv'; import { createLogger } from '@shared/utils/logger'; import type { IPty } from 'node-pty'; import type * as NodePty from 'node-pty'; const logger = createLogger('ClaudeDoctorProbe'); const DOCTOR_TIMEOUT_MS = 12_000; const DOCTOR_COLS = 240; const DOCTOR_ROWS = 40; const DOCTOR_MAX_OUTPUT_CHARS = 128_000; const DOCTOR_CONTINUE_PROMPT_RE = /Press (?:Enter|any key) to continue/i; const DOCTOR_FIELD_RE = /^\s*[│├└L|]?\s*[A-Za-z][A-Za-z0-9 /()_-]*:\s*/; const DOCTOR_SECTION_RE = /^\s*(?:Diagnostics|Updates|Version Locks|Plugin Errors|Context Usage Warnings)\s*$/i; const DOCTOR_SEPARATOR_RE = /^\s*[\u2500\u2501-]{6,}\s*$/; type NodePtyModule = typeof NodePty; let nodePty: NodePtyModule | null = null; try { // eslint-disable-next-line @typescript-eslint/no-require-imports -- node-pty is optional native addon nodePty = require('node-pty') as NodePtyModule; } catch { logger.warn('node-pty not available - doctor fallback disabled'); } function stripAnsiSequences(value: string): string { return value .replace(/\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g, '') .replace(/\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, '') .replace(/\u009B[0-?]*[ -/]*[@-~]/g, ''); } function normalizeDoctorOutput(output: string): string { return stripAnsiSequences(output) .replace(/\r\n/g, '\n') .replace(/\r/g, '\n') .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, ''); } function isDoctorStopLine(line: string): boolean { const trimmed = line.trim(); if (!trimmed) { return true; } return ( DOCTOR_CONTINUE_PROMPT_RE.test(trimmed) || DOCTOR_SECTION_RE.test(trimmed) || DOCTOR_SEPARATOR_RE.test(trimmed) || DOCTOR_FIELD_RE.test(line) ); } export function extractDoctorInvokedCandidates(output: string): string[] { const normalized = normalizeDoctorOutput(output); const lines = normalized.split('\n'); const candidates: string[] = []; let index = 0; while (index < lines.length) { const line = lines[index] ?? ''; const markerIndex = line.indexOf('Invoked:'); if (markerIndex < 0) { index += 1; continue; } const parts = [line.slice(markerIndex + 'Invoked:'.length).trimStart()]; let cursor = index + 1; while (cursor < lines.length) { const nextLine = lines[cursor] ?? ''; if (isDoctorStopLine(nextLine)) { break; } parts.push(nextLine.trimStart()); cursor += 1; } const candidate = parts.join('').trim(); if (candidate.length > 0) { candidates.push(candidate); } index = Math.max(index + 1, cursor); } return candidates; } async function captureDoctorOutput(commandName: string): Promise { if (!nodePty) { return null; } const env = { ...buildEnrichedEnv(), COLUMNS: String(DOCTOR_COLS), LINES: String(DOCTOR_ROWS), }; const cwd = getShellPreferredHome(); return new Promise((resolve) => { let transcript = ''; let settled = false; let continueSent = false; let pty: IPty | null = null; let timeoutHandle: NodeJS.Timeout | null = null; const finalize = (output: string | null): void => { if (settled) { return; } settled = true; if (timeoutHandle) { clearTimeout(timeoutHandle); timeoutHandle = null; } try { pty?.kill(); } catch { /* already closed */ } resolve(output); }; try { if (process.platform === 'win32') { const shell = process.env.COMSPEC ?? 'cmd.exe'; pty = nodePty.spawn(shell, ['/d', '/c', commandName, 'doctor'], { name: 'xterm-256color', cols: DOCTOR_COLS, rows: DOCTOR_ROWS, cwd, env: env as Record, }); } else { pty = nodePty.spawn(commandName, ['doctor'], { name: 'xterm-256color', cols: DOCTOR_COLS, rows: DOCTOR_ROWS, cwd, env: env as Record, }); } } catch (error) { logger.warn(`Failed to start doctor fallback for ${commandName}: ${String(error)}`); finalize(null); return; } timeoutHandle = setTimeout(() => { logger.warn(`Doctor fallback timed out after ${DOCTOR_TIMEOUT_MS}ms for ${commandName}`); finalize(transcript); }, DOCTOR_TIMEOUT_MS); pty.onData((chunk) => { transcript = (transcript + chunk).slice(-DOCTOR_MAX_OUTPUT_CHARS); if (!continueSent && DOCTOR_CONTINUE_PROMPT_RE.test(normalizeDoctorOutput(transcript))) { continueSent = true; try { pty?.write('\r'); } catch { /* PTY already exited */ } } }); pty.onExit(() => finalize(transcript)); }); } export async function getDoctorInvokedCandidates(commandName: string): Promise { const output = await captureDoctorOutput(commandName); if (!output) { return []; } return extractDoctorInvokedCandidates(output); }