diff --git a/src/server/__tests__/doctor-service.test.ts b/src/server/__tests__/doctor-service.test.ts
new file mode 100644
index 00000000..2333c103
--- /dev/null
+++ b/src/server/__tests__/doctor-service.test.ts
@@ -0,0 +1,180 @@
+import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
+import * as fs from 'node:fs/promises'
+import * as os from 'node:os'
+import * as path from 'node:path'
+import { handleDoctorApi } from '../api/doctor.js'
+import { handleApiRequest } from '../router.js'
+import { DoctorService } from '../services/doctorService.js'
+
+let tmpDir: string
+let homeDir: string
+let configDir: string
+let projectRoot: string
+let originalConfigDir: string | undefined
+
+beforeEach(async () => {
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'doctor-service-test-'))
+ homeDir = path.join(tmpDir, 'home')
+ configDir = path.join(homeDir, '.claude')
+ projectRoot = path.join(tmpDir, 'workspace', 'demo-project')
+ originalConfigDir = process.env.CLAUDE_CONFIG_DIR
+ process.env.CLAUDE_CONFIG_DIR = configDir
+
+ await fs.mkdir(path.join(configDir, 'projects', 'demo-project'), { recursive: true })
+ await fs.mkdir(path.join(configDir, 'skills', 'alpha-skill'), { recursive: true })
+ await fs.mkdir(path.join(configDir, 'cc-haha'), { recursive: true })
+ await fs.mkdir(path.join(projectRoot, '.claude', 'skills', 'beta-skill'), { recursive: true })
+
+ await fs.writeFile(path.join(configDir, 'settings.json'), '{"defaultMode":', 'utf-8')
+ await fs.writeFile(path.join(projectRoot, '.claude', 'settings.json'), '{"model":', 'utf-8')
+ await fs.writeFile(
+ path.join(configDir, 'projects', 'demo-project', 'session-1.jsonl'),
+ '{"type":"message"}\n{bad json}\n',
+ 'utf-8',
+ )
+ await fs.writeFile(
+ path.join(configDir, 'cc-haha', 'providers.json'),
+ JSON.stringify({ activeId: null, providers: [{ id: 'provider-1' }] }),
+ 'utf-8',
+ )
+ await fs.writeFile(
+ path.join(configDir, 'skills', 'alpha-skill', 'SKILL.md'),
+ '# Alpha\n',
+ 'utf-8',
+ )
+ await fs.writeFile(
+ path.join(projectRoot, '.claude', 'skills', 'beta-skill', 'SKILL.md'),
+ '# Beta\n',
+ 'utf-8',
+ )
+})
+
+afterEach(async () => {
+ if (originalConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR
+ else process.env.CLAUDE_CONFIG_DIR = originalConfigDir
+ await fs.rm(tmpDir, { recursive: true, force: true })
+})
+
+function makeRequest(
+ method: string,
+ urlStr: string,
+ body?: Record
,
+): { req: Request; url: URL; segments: string[] } {
+ const url = new URL(urlStr, 'http://localhost:3456')
+ const init: RequestInit = { method }
+ if (body) {
+ init.headers = { 'Content-Type': 'application/json' }
+ init.body = JSON.stringify(body)
+ }
+ const req = new Request(url.toString(), init)
+ const segments = url.pathname.split('/').filter(Boolean)
+ return { req, url, segments }
+}
+
+describe('DoctorService', () => {
+ test('report redacts filesystem paths and lists protected skipped items', async () => {
+ const service = new DoctorService({ configDir, homeDir, projectRoot })
+
+ const report = await service.getReport()
+ const serialized = JSON.stringify(report)
+
+ expect(serialized).not.toContain(tmpDir)
+ expect(serialized).not.toContain(projectRoot)
+
+ const userSettings = report.items.find((item) => item.path === '~/.claude/settings.json')
+ expect(userSettings).toBeDefined()
+ expect(userSettings?.protected).toBe(true)
+ expect(userSettings?.status).toBe('invalid_json')
+
+ const projectSettings = report.items.find(
+ (item) => item.path === '/.claude/settings.json',
+ )
+ expect(projectSettings).toBeDefined()
+ expect(projectSettings?.protected).toBe(true)
+ expect(projectSettings?.status).toBe('invalid_json')
+
+ const sessionJsonl = report.items.find(
+ (item) => item.path === '~/.claude/projects/demo-project/session-1.jsonl',
+ )
+ expect(sessionJsonl).toBeDefined()
+ expect(sessionJsonl?.protected).toBe(true)
+ expect(sessionJsonl?.status).toBe('invalid_jsonl')
+ expect(sessionJsonl?.lineCount).toBe(2)
+ expect(sessionJsonl?.invalidLineCount).toBe(1)
+
+ expect(report.protectedSkips).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ path: '~/.claude/settings.json', reason: 'protected' }),
+ expect.objectContaining({ path: '/.claude/settings.json', reason: 'protected' }),
+ expect.objectContaining({
+ path: '~/.claude/projects/demo-project/session-1.jsonl',
+ reason: 'protected',
+ }),
+ ]),
+ )
+ })
+
+ test('safe repair skips protected malformed files without modifying them', async () => {
+ const service = new DoctorService({ configDir, homeDir, projectRoot })
+ const userSettingsPath = path.join(configDir, 'settings.json')
+ const projectSettingsPath = path.join(projectRoot, '.claude', 'settings.json')
+ const beforeUser = await fs.readFile(userSettingsPath, 'utf-8')
+ const beforeProject = await fs.readFile(projectSettingsPath, 'utf-8')
+
+ const result = await service.repair()
+
+ expect(result.mutated).toBe(false)
+ expect(result.operations).toEqual([])
+ expect(result.skips).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ path: '~/.claude/settings.json', reason: 'protected' }),
+ expect.objectContaining({ path: '/.claude/settings.json', reason: 'protected' }),
+ ]),
+ )
+
+ expect(await fs.readFile(userSettingsPath, 'utf-8')).toBe(beforeUser)
+ expect(await fs.readFile(projectSettingsPath, 'utf-8')).toBe(beforeProject)
+ })
+})
+
+describe('doctor API', () => {
+ test('returns a report and dry-run repair result', async () => {
+ const reportReq = makeRequest(
+ 'GET',
+ `/api/doctor/report?cwd=${encodeURIComponent(projectRoot)}`,
+ )
+ const reportRes = await handleDoctorApi(reportReq.req, reportReq.url, reportReq.segments)
+ expect(reportRes.status).toBe(200)
+ const reportBody = await reportRes.json() as {
+ report: { summary: { protectedCount: number } }
+ }
+ expect(reportBody.report.summary.protectedCount).toBeGreaterThan(0)
+
+ const repairReq = makeRequest('POST', '/api/doctor/repair', { cwd: projectRoot })
+ const repairRes = await handleDoctorApi(repairReq.req, repairReq.url, repairReq.segments)
+ expect(repairRes.status).toBe(200)
+ const repairBody = await repairRes.json() as { result: { mutated: boolean } }
+ expect(repairBody.result.mutated).toBe(false)
+ })
+
+ test('routes doctor requests through the main API router', async () => {
+ const url = new URL(
+ `/api/doctor/report?cwd=${encodeURIComponent(projectRoot)}`,
+ 'http://localhost:3456',
+ )
+ const res = await handleApiRequest(new Request(url.toString(), { method: 'GET' }), url)
+
+ expect(res.status).toBe(200)
+ const body = await res.json() as {
+ report: { items: Array<{ path: string; status: string }> }
+ }
+ expect(body.report.items).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ path: '~/.claude/settings.json',
+ status: 'invalid_json',
+ }),
+ ]),
+ )
+ })
+})
diff --git a/src/server/api/doctor.ts b/src/server/api/doctor.ts
new file mode 100644
index 00000000..6fba2dd8
--- /dev/null
+++ b/src/server/api/doctor.ts
@@ -0,0 +1,57 @@
+import { ApiError, errorResponse } from '../middleware/errorHandler.js'
+import { DoctorService } from '../services/doctorService.js'
+
+export async function handleDoctorApi(
+ req: Request,
+ url: URL,
+ segments: string[],
+): Promise {
+ try {
+ const sub = segments[2]
+
+ if ((req.method === 'GET' && !sub) || (req.method === 'GET' && sub === 'report')) {
+ const cwd = url.searchParams.get('cwd') || undefined
+ const service = new DoctorService({ projectRoot: cwd })
+ return Response.json({ report: await service.getReport() })
+ }
+
+ if (req.method === 'POST' && sub === 'repair') {
+ const body = await parseJsonBody(req)
+ const cwd = typeof body.cwd === 'string' ? body.cwd : url.searchParams.get('cwd') || undefined
+ const targetIds = Array.isArray(body.targetIds)
+ ? body.targetIds.filter((value): value is string => typeof value === 'string' && value.length > 0)
+ : undefined
+ const service = new DoctorService({ projectRoot: cwd })
+ return Response.json({ result: await service.repair(targetIds) })
+ }
+
+ if (!sub) {
+ throw methodNotAllowed(req.method, '/api/doctor')
+ }
+
+ throw ApiError.notFound(`Unknown doctor endpoint: ${sub}`)
+ } catch (error) {
+ return errorResponse(error)
+ }
+}
+
+async function parseJsonBody(req: Request): Promise> {
+ if (
+ !req.headers.get('content-length') &&
+ !req.headers.get('transfer-encoding') &&
+ !req.headers.get('content-type')
+ ) {
+ return {}
+ }
+
+ try {
+ const body = await req.json()
+ return body && typeof body === 'object' ? body as Record : {}
+ } catch {
+ throw ApiError.badRequest('Invalid JSON body')
+ }
+}
+
+function methodNotAllowed(method: string, route: string): ApiError {
+ return new ApiError(405, `Method ${method} not allowed on ${route}`, 'METHOD_NOT_ALLOWED')
+}
diff --git a/src/server/router.ts b/src/server/router.ts
index 92dfe07e..a66e778a 100644
--- a/src/server/router.ts
+++ b/src/server/router.ts
@@ -21,6 +21,7 @@ import { handleHahaOAuthApi } from './api/haha-oauth.js'
import { handleHahaOpenAIOAuthApi } from './api/haha-openai-oauth.js'
import { handleMcpApi } from './api/mcp.js'
import { handleDiagnosticsApi } from './api/diagnostics.js'
+import { handleDoctorApi } from './api/doctor.js'
export async function handleApiRequest(req: Request, url: URL): Promise {
const path = url.pathname
@@ -95,6 +96,9 @@ export async function handleApiRequest(req: Request, url: URL): 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('/')
+}