import * as fs from 'node:fs/promises' import * as os from 'node:os' import * as path from 'node:path' import type { Dirent } from 'node:fs' import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' import { diagnosticsService } from './diagnosticsService.js' export type DoctorItemKind = 'json' | 'jsonl' | 'directory' export type DoctorItemStatus = 'ok' | 'missing' | 'invalid_json' | 'invalid_jsonl' | 'unreadable' export type DoctorSkipReason = 'protected' export type DoctorReportItem = { id: string label: string kind: DoctorItemKind scope: 'user' | 'project' path: string protected: boolean exists: boolean status: DoctorItemStatus bytes: number entryCount?: number lineCount?: number invalidLineCount?: number error?: string } export type DoctorProtectedSkip = { id: string path: string reason: DoctorSkipReason } export type DoctorReport = { generatedAt: string items: DoctorReportItem[] protectedSkips: DoctorProtectedSkip[] summary: { total: number protectedCount: number missingCount: number invalidCount: number } } export type DoctorRepairOperation = { id: string path: string action: 'would_repair' } export type DoctorRepairSkip = { id: string path: string reason: DoctorSkipReason } export type DoctorRepairResult = { dryRun: true mutated: false operations: DoctorRepairOperation[] skips: DoctorRepairSkip[] summary: { operationCount: number skipCount: number } } type DoctorServiceOptions = { configDir?: string homeDir?: string projectRoot?: string } type DoctorTarget = { id: string label: string kind: DoctorItemKind scope: 'user' | 'project' filePath: string protected: true } export class DoctorService { private readonly configDir: string private readonly homeDir: string private readonly projectRoot?: string private readonly usesConfigDirOverride: boolean constructor(options: DoctorServiceOptions = {}) { this.configDir = options.configDir || getClaudeConfigHomeDir() this.homeDir = options.homeDir || inferHomeDir(this.configDir) this.projectRoot = options.projectRoot this.usesConfigDirOverride = Boolean(options.configDir || process.env.CLAUDE_CONFIG_DIR) } async getReport(): Promise { const targets = await this.buildTargets() const items = await Promise.all(targets.map((target) => this.inspectTarget(target))) const protectedSkips = items .filter((item) => item.protected) .map((item) => ({ id: item.id, path: item.path, reason: 'protected' as const, })) return { generatedAt: new Date().toISOString(), items, protectedSkips, summary: { total: items.length, protectedCount: protectedSkips.length, missingCount: items.filter((item) => item.status === 'missing').length, invalidCount: items.filter((item) => item.status === 'invalid_json' || item.status === 'invalid_jsonl' || item.status === 'unreadable' ).length, }, } } async repair(targetIds?: string[]): Promise { const report = await this.getReport() const selectedIds = targetIds?.length ? new Set(targetIds) : null const selectedItems = selectedIds ? report.items.filter((item) => selectedIds.has(item.id)) : report.items const operations: DoctorRepairOperation[] = [] const skips = selectedItems.map((item) => { if (!item.protected && item.status !== 'ok') { operations.push({ id: item.id, path: item.path, action: 'would_repair', }) } return { id: item.id, path: item.path, reason: 'protected' as const, } }) return { dryRun: true, mutated: false, operations, skips, summary: { operationCount: operations.length, skipCount: skips.length, }, } } private async buildTargets(): Promise { const targets: DoctorTarget[] = [ this.jsonTarget('user-settings', 'User settings', 'user', path.join(this.configDir, 'settings.json')), this.jsonTarget( 'cc-haha-providers', 'Managed providers', 'user', path.join(this.configDir, 'cc-haha', 'providers.json'), ), this.jsonTarget( 'cc-haha-settings', 'Managed provider settings', 'user', path.join(this.configDir, 'cc-haha', 'settings.json'), ), this.jsonTarget('adapters', 'Adapters config', 'user', path.join(this.configDir, 'adapters.json')), this.jsonTarget( 'adapter-sessions', 'Adapter sessions', 'user', path.join(this.configDir, 'adapter-sessions.json'), ), this.directoryTarget('user-skills', 'User skills', 'user', path.join(this.configDir, 'skills')), this.directoryTarget('teams', 'Teams', 'user', path.join(this.configDir, 'teams')), this.directoryTarget('plugins', 'Plugins', 'user', path.join(this.configDir, 'plugins')), this.directoryTarget( 'cowork-plugins', 'Cowork plugins', 'user', path.join(this.configDir, 'cowork_plugins'), ), this.jsonTarget('user-mcp', 'User MCP config', 'user', this.getUserMcpConfigPath()), this.jsonTarget('oauth', 'OAuth tokens', 'user', path.join(this.configDir, 'cc-haha', 'oauth.json')), this.jsonTarget( 'openai-oauth', 'OpenAI OAuth tokens', 'user', path.join(this.configDir, 'cc-haha', 'openai-oauth.json'), ), ] if (this.projectRoot) { targets.push( this.jsonTarget( 'project-settings', 'Project settings', 'project', path.join(this.projectRoot, '.claude', 'settings.json'), ), this.directoryTarget( 'project-skills', 'Project skills', 'project', path.join(this.projectRoot, '.claude', 'skills'), ), this.jsonTarget( 'project-mcp', 'Project MCP config', 'project', path.join(this.projectRoot, '.mcp.json'), ), ) } const sessionFiles = await this.listJsonlFiles(path.join(this.configDir, 'projects')) for (const filePath of sessionFiles) { const relativePath = toPosix(path.relative(this.configDir, filePath)) targets.push( this.jsonlTarget( `session-jsonl:${relativePath}`, 'Session transcript', 'user', filePath, ), ) } return targets } private async inspectTarget(target: DoctorTarget): Promise { switch (target.kind) { case 'json': return this.inspectJsonTarget(target) case 'jsonl': return this.inspectJsonlTarget(target) case 'directory': return this.inspectDirectoryTarget(target) default: return this.inspectMissingTarget(target) } } private async inspectJsonTarget(target: DoctorTarget): Promise { const exists = await this.pathExists(target.filePath) if (!exists) return this.inspectMissingTarget(target) let raw: string try { raw = await fs.readFile(target.filePath, 'utf-8') } catch (error) { return this.inspectUnreadableTarget(target, error) } const bytes = Buffer.byteLength(raw, 'utf-8') if (!raw.trim()) { return { ...this.baseItem(target), exists: true, status: 'invalid_json', bytes, error: 'Empty JSON file', } } try { const parsed = JSON.parse(raw) return { ...this.baseItem(target), exists: true, status: 'ok', bytes, entryCount: countJsonEntries(parsed), } } catch (error) { return { ...this.baseItem(target), exists: true, status: 'invalid_json', bytes, error: this.sanitizeText(error instanceof Error ? error.message : String(error)), } } } private async inspectJsonlTarget(target: DoctorTarget): Promise { const exists = await this.pathExists(target.filePath) if (!exists) return this.inspectMissingTarget(target) let raw: string try { raw = await fs.readFile(target.filePath, 'utf-8') } catch (error) { return this.inspectUnreadableTarget(target, error) } const lines = raw.split(/\r?\n/).filter((line) => line.trim().length > 0) let invalidLineCount = 0 for (const line of lines) { try { JSON.parse(line) } catch { invalidLineCount += 1 } } return { ...this.baseItem(target), exists: true, status: invalidLineCount > 0 ? 'invalid_jsonl' : 'ok', bytes: Buffer.byteLength(raw, 'utf-8'), lineCount: lines.length, invalidLineCount, } } private async inspectDirectoryTarget(target: DoctorTarget): Promise { const exists = await this.pathExists(target.filePath) if (!exists) return this.inspectMissingTarget(target) let entries: Dirent[] try { entries = await fs.readdir(target.filePath, { withFileTypes: true }) } catch (error) { return this.inspectUnreadableTarget(target, error) } return { ...this.baseItem(target), exists: true, status: 'ok', bytes: 0, entryCount: countVisibleEntries(entries), } } private inspectMissingTarget(target: DoctorTarget): DoctorReportItem { return { ...this.baseItem(target), exists: false, status: 'missing', bytes: 0, } } private inspectUnreadableTarget(target: DoctorTarget, error: unknown): DoctorReportItem { return { ...this.baseItem(target), exists: true, status: 'unreadable', bytes: 0, error: this.sanitizeText(error instanceof Error ? error.message : String(error)), } } private baseItem(target: DoctorTarget): Omit { return { id: target.id, label: target.label, kind: target.kind, scope: target.scope, path: this.sanitizePath(target.filePath), protected: target.protected, } } private sanitizePath(filePath: string): string { if (this.projectRoot && isWithinRoot(filePath, this.projectRoot)) { return this.withAlias('', filePath, this.projectRoot) } if (isWithinRoot(filePath, this.configDir)) { return this.withAlias('~/.claude', filePath, this.configDir) } if (isWithinRoot(filePath, this.homeDir)) { return this.withAlias('~', filePath, this.homeDir) } return this.sanitizeText(filePath) } private sanitizeText(value: string): string { let sanitized = diagnosticsService.sanitizeString(value) const replacements: Array<[string | undefined, string]> = [ [this.projectRoot, ''], [this.configDir, '~/.claude'], [this.homeDir, '~'], ] for (const [from, to] of replacements) { if (!from) continue sanitized = sanitized.split(from).join(to) } return sanitized } private getUserMcpConfigPath(): string { if (this.usesConfigDirOverride) { return path.join(this.configDir, '.claude.json') } return path.join(this.homeDir, '.claude.json') } private async listJsonlFiles(rootDir: string): Promise { const exists = await this.pathExists(rootDir) if (!exists) return [] const results: string[] = [] const stack = [rootDir] while (stack.length > 0) { const current = stack.pop() if (!current) continue let entries: Dirent[] try { entries = await fs.readdir(current, { withFileTypes: true }) } catch { continue } for (const entry of entries) { const fullPath = path.join(current, entry.name) if (entry.isDirectory()) { stack.push(fullPath) continue } if (entry.isFile() && fullPath.endsWith('.jsonl')) { results.push(fullPath) } } } results.sort((left, right) => left.localeCompare(right)) return results } private async pathExists(filePath: string): Promise { try { await fs.access(filePath) return true } catch { return false } } private jsonTarget( id: string, label: string, scope: 'user' | 'project', filePath: string, ): DoctorTarget { return { id, label, kind: 'json', scope, filePath, protected: true } } private jsonlTarget( id: string, label: string, scope: 'user' | 'project', filePath: string, ): DoctorTarget { return { id, label, kind: 'jsonl', scope, filePath, protected: true } } private directoryTarget( id: string, label: string, scope: 'user' | 'project', filePath: string, ): DoctorTarget { return { id, label, kind: 'directory', scope, filePath, protected: true } } private withAlias(alias: string, filePath: string, root: string): string { const relativePath = path.relative(root, filePath) if (!relativePath) return alias return `${alias}/${toPosix(relativePath)}` } } function countJsonEntries(value: unknown): number { if (Array.isArray(value)) return value.length if (value && typeof value === 'object') return Object.keys(value as Record).length if (value === null || value === undefined) return 0 return 1 } function countVisibleEntries(entries: Dirent[]): number { return entries.filter((entry) => entry.name !== '.DS_Store').length } function inferHomeDir(configDir: string): string { if (path.basename(configDir) === '.claude') { return path.dirname(configDir) } return os.homedir() } function isWithinRoot(filePath: string, root: string): boolean { const relativePath = path.relative(root, filePath) return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)) } function toPosix(value: string): string { return value.split(path.sep).join('/') }