Browse Source

Add doctor CLI

maou-shonen 3 months ago
parent
commit
195e7efbc6
5 changed files with 825 additions and 30 deletions
  1. 2 2
      docs/installation.md
  2. 487 0
      src/cli/doctor.test.ts
  3. 272 0
      src/cli/doctor.ts
  4. 12 1
      src/cli/index.ts
  5. 52 27
      src/config/loader.ts

+ 2 - 2
docs/installation.md

@@ -194,9 +194,9 @@ If the installer reports that the configuration already exists, you have two opt
    opencode auth status
    ```
 
-2. Verify your config file exists and is valid:
+2. From your project root, verify your config file exists and is valid:
    ```bash
-   cat ~/.config/opencode/oh-my-opencode-slim.json
+   bunx oh-my-opencode-slim@latest doctor
    ```
 
 3. Check that your provider is configured in `~/.config/opencode/opencode.json`

+ 487 - 0
src/cli/doctor.test.ts

@@ -0,0 +1,487 @@
+import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import {
+  doctor,
+  formatJsonDoctorResult,
+  parseDoctorArgs,
+  runDoctorCheck,
+} from './doctor';
+
+describe('parseDoctorArgs', () => {
+  test('no args returns empty', () => {
+    const result = parseDoctorArgs([]);
+    expect(result).toEqual({});
+  });
+
+  test('--json sets json flag', () => {
+    const result = parseDoctorArgs(['--json']);
+    expect(result.json).toBe(true);
+  });
+
+  test('--help sets help flag', () => {
+    const result = parseDoctorArgs(['--help']);
+    expect(result).toEqual({ help: true });
+  });
+
+  test('unknown option returns error', () => {
+    const result = parseDoctorArgs(['--project']);
+    expect(result.error).toBe('Unknown doctor option: --project');
+  });
+
+  test('positional arg returns error', () => {
+    const result = parseDoctorArgs(['/my/project']);
+    expect(result.error).toBe('Unknown doctor option: /my/project');
+  });
+});
+
+describe('runDoctorCheck', () => {
+  let tempDir: string;
+  let originalCwd: string;
+  let originalEnv: typeof process.env;
+
+  function setupTempDir(): string {
+    const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'doctor-test-'));
+    return dir;
+  }
+
+  beforeEach(() => {
+    tempDir = setupTempDir();
+    originalCwd = process.cwd();
+    originalEnv = { ...process.env };
+    delete process.env.OPENCODE_CONFIG_DIR;
+    delete process.env.OH_MY_OPENCODE_SLIM_PRESET;
+    process.env.XDG_CONFIG_HOME = path.join(tempDir, 'user-config');
+  });
+
+  afterEach(() => {
+    process.chdir(originalCwd);
+    fs.rmSync(tempDir, { recursive: true, force: true });
+    process.env = originalEnv;
+  });
+
+  test('no config files returns ok', () => {
+    const projectDir = path.join(tempDir, 'project');
+    fs.mkdirSync(projectDir, { recursive: true });
+
+    const result = runDoctorCheck(projectDir);
+
+    expect(result.ok).toBe(true);
+    expect(result.configs).toHaveLength(2);
+    expect(result.configs[0].scope).toBe('user');
+    expect(result.configs[0].exists).toBe(false);
+    expect(result.configs[1].scope).toBe('project');
+    expect(result.configs[1].exists).toBe(false);
+    expect(result.presetCheck).toBeUndefined();
+  });
+
+  test('valid project config returns ok', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const configDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(configDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(configDir, 'oh-my-opencode-slim.jsonc'),
+      `{
+        // JSONC comments are supported.
+        "agents": {
+          "oracle": { "model": "test/model" },
+        },
+      }`,
+    );
+
+    const result = runDoctorCheck(projectDir);
+
+    expect(result.ok).toBe(true);
+    expect(result.configs[1].ok).toBe(true);
+    expect(result.configs[1].path).toContain('.jsonc');
+  });
+
+  test('invalid JSON returns not ok with invalid-json error', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const configDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(configDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(configDir, 'oh-my-opencode-slim.json'),
+      '{ invalid json }',
+    );
+
+    const result = runDoctorCheck(projectDir);
+
+    expect(result.ok).toBe(false);
+    expect(result.configs[1].ok).toBe(false);
+    expect(result.configs[1].error?.kind).toBe('invalid-json');
+    expect(result.presetCheck).toBeUndefined();
+  });
+
+  test('invalid schema returns not ok with schema issues', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const configDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(configDir, { recursive: true });
+    // temperature must be 0-2
+    fs.writeFileSync(
+      path.join(configDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({ agents: { oracle: { temperature: 99 } } }),
+    );
+
+    const result = runDoctorCheck(projectDir);
+
+    expect(result.ok).toBe(false);
+    expect(result.configs[1].ok).toBe(false);
+    expect(result.configs[1].error?.kind).toBe('invalid-schema');
+    expect(result.configs[1].error?.issues).toBeDefined();
+    expect(result.configs[1].error?.issues[0].path).toContain('temperature');
+  });
+
+  test('multiple schema errors includes relevant paths', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const configDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(configDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(configDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({
+        agents: { oracle: { temperature: 99 } },
+        multiplexer: { type: 'unknown' },
+      }),
+    );
+
+    const result = runDoctorCheck(projectDir);
+
+    expect(result.ok).toBe(false);
+    expect(result.configs[1].error?.kind).toBe('invalid-schema');
+    const issuePaths = result.configs[1].error?.issues?.map((i) =>
+      i.path.join('.'),
+    );
+    expect(issuePaths).toContain('agents.oracle.temperature');
+    expect(issuePaths).toContain('multiplexer.type');
+  });
+
+  test('empty config file returns invalid-json error', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const configDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(configDir, { recursive: true });
+    fs.writeFileSync(path.join(configDir, 'oh-my-opencode-slim.json'), '');
+
+    const result = runDoctorCheck(projectDir);
+
+    expect(result.ok).toBe(false);
+    expect(result.configs[1].error?.kind).toBe('invalid-json');
+    expect(result.configs[1].error?.message).toContain('Empty file');
+  });
+
+  test('invalid user config skips preset check', () => {
+    const userOpencodeDir = path.join(tempDir, 'user-config', 'opencode');
+    fs.mkdirSync(userOpencodeDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(userOpencodeDir, 'oh-my-opencode-slim.json'),
+      '{ invalid }',
+    );
+
+    const projectDir = path.join(tempDir, 'project');
+    const configDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(configDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(configDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({
+        preset: 'mypreset',
+        presets: { mypreset: { oracle: { model: 'test/model' } } },
+      }),
+    );
+
+    const result = runDoctorCheck(projectDir);
+
+    expect(result.ok).toBe(false);
+    expect(result.configs[0].ok).toBe(false);
+    expect(result.presetCheck).toBeUndefined();
+  });
+
+  test('preset check passes with valid preset', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const configDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(configDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(configDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({
+        preset: 'mypreset',
+        presets: { mypreset: { oracle: { model: 'test/model' } } },
+      }),
+    );
+
+    const result = runDoctorCheck(projectDir);
+
+    expect(result.ok).toBe(true);
+    expect(result.presetCheck).toEqual({ preset: 'mypreset', ok: true });
+  });
+
+  test('preset check fails for missing preset', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const configDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(configDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(configDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({
+        preset: 'nonexistent',
+        presets: { other: {} },
+      }),
+    );
+
+    const result = runDoctorCheck(projectDir);
+
+    expect(result.ok).toBe(false);
+    expect(result.presetCheck?.ok).toBe(false);
+    expect(result.presetCheck?.error?.kind).toBe('missing-preset');
+  });
+
+  test('env preset overrides config preset', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const configDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(configDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(configDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({
+        preset: 'config-preset',
+        presets: {
+          'config-preset': { oracle: { model: 'config/model' } },
+          'env-preset': { oracle: { model: 'env/model' } },
+        },
+      }),
+    );
+
+    process.env.OH_MY_OPENCODE_SLIM_PRESET = 'env-preset';
+
+    const result = runDoctorCheck(projectDir);
+
+    expect(result.ok).toBe(true);
+    expect(result.presetCheck?.preset).toBe('env-preset');
+    expect(result.presetCheck?.ok).toBe(true);
+  });
+
+  test('project config overrides user config with merge', () => {
+    const userOpencodeDir = path.join(tempDir, 'user-config', 'opencode');
+    fs.mkdirSync(userOpencodeDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(userOpencodeDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({
+        agents: { oracle: { temperature: 0.5 } },
+        presets: {
+          'test-preset': {
+            oracle: { model: 'user/model' },
+            explorer: { model: 'user/explorer' },
+          },
+        },
+      }),
+    );
+
+    const projectDir = path.join(tempDir, 'project');
+    const configDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(configDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(configDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({
+        preset: 'test-preset',
+        agents: { oracle: { model: 'project/model' } },
+      }),
+    );
+
+    const result = runDoctorCheck(projectDir);
+
+    expect(result.ok).toBe(true);
+    expect(result.presetCheck?.preset).toBe('test-preset');
+    expect(result.presetCheck?.ok).toBe(true);
+    expect(result.configs[0].config?.agents?.oracle?.temperature).toBe(0.5);
+    expect(result.configs[1].config?.agents?.oracle?.model).toBe(
+      'project/model',
+    );
+  });
+
+  test('json formatter omits parsed config payload', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const configDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(configDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(configDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({ agents: { oracle: { model: 'secret/model' } } }),
+    );
+
+    const result = runDoctorCheck(projectDir);
+    const parsed = JSON.parse(formatJsonDoctorResult(result));
+
+    expect(result.configs[1].config?.agents?.oracle?.model).toBe(
+      'secret/model',
+    );
+    expect(parsed.configs[1].config).toBeUndefined();
+  });
+
+  test('project preset overrides user preset', () => {
+    const userOpencodeDir = path.join(tempDir, 'user-config', 'opencode');
+    fs.mkdirSync(userOpencodeDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(userOpencodeDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({
+        preset: 'user-preset',
+        presets: {
+          'user-preset': { oracle: { model: 'user/model' } },
+          'project-preset': { oracle: { model: 'project/model' } },
+        },
+      }),
+    );
+
+    const projectDir = path.join(tempDir, 'project');
+    const configDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(configDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(configDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({
+        preset: 'project-preset',
+      }),
+    );
+
+    const result = runDoctorCheck(projectDir);
+
+    expect(result.ok).toBe(true);
+    expect(result.presetCheck?.preset).toBe('project-preset');
+    expect(result.presetCheck?.ok).toBe(true);
+  });
+
+  test('.jsonc preferred over .json', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const configDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(configDir, { recursive: true });
+
+    fs.writeFileSync(
+      path.join(configDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({ agents: { oracle: { model: 'json-model' } } }),
+    );
+    fs.writeFileSync(
+      path.join(configDir, 'oh-my-opencode-slim.jsonc'),
+      JSON.stringify({ agents: { oracle: { model: 'jsonc-model' } } }),
+    );
+
+    const result = runDoctorCheck(projectDir);
+
+    expect(result.ok).toBe(true);
+    expect(result.configs[1].path).toContain('.jsonc');
+  });
+});
+
+describe('doctor CLI wrapper', () => {
+  let tempDir: string;
+  let originalCwd: string;
+  let originalEnv: typeof process.env;
+
+  async function runDoctorCliFrom(
+    projectDir: string,
+    args: Parameters<typeof doctor>[0] = {},
+  ): Promise<number> {
+    process.chdir(projectDir);
+    try {
+      return await doctor(args);
+    } finally {
+      process.chdir(originalCwd);
+    }
+  }
+
+  beforeEach(() => {
+    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'doctor-cli-test-'));
+    originalCwd = process.cwd();
+    originalEnv = { ...process.env };
+    delete process.env.OPENCODE_CONFIG_DIR;
+    delete process.env.OH_MY_OPENCODE_SLIM_PRESET;
+    process.env.XDG_CONFIG_HOME = path.join(tempDir, 'user-config');
+  });
+
+  afterEach(() => {
+    process.chdir(originalCwd);
+    fs.rmSync(tempDir, { recursive: true, force: true });
+    process.env = originalEnv;
+  });
+
+  test('help exits 0', async () => {
+    const projectDir = path.join(tempDir, 'project');
+    fs.mkdirSync(projectDir, { recursive: true });
+
+    const exitCode = await runDoctorCliFrom(projectDir, { help: true });
+    expect(exitCode).toBe(0);
+  });
+
+  test('unknown arg exits 1', async () => {
+    const projectDir = path.join(tempDir, 'project');
+    fs.mkdirSync(projectDir, { recursive: true });
+
+    const exitCode = await runDoctorCliFrom(projectDir, {
+      error: 'Unknown doctor option: --bad',
+    });
+    expect(exitCode).toBe(1);
+  });
+
+  test('no config exits 0', async () => {
+    const projectDir = path.join(tempDir, 'project');
+    fs.mkdirSync(projectDir, { recursive: true });
+
+    const exitCode = await runDoctorCliFrom(projectDir);
+    expect(exitCode).toBe(0);
+  });
+
+  test('invalid config exits 1', async () => {
+    const projectDir = path.join(tempDir, 'project');
+    const configDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(configDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(configDir, 'oh-my-opencode-slim.json'),
+      '{ invalid }',
+    );
+
+    const exitCode = await runDoctorCliFrom(projectDir);
+    expect(exitCode).toBe(1);
+  });
+
+  test('--json mode outputs valid JSON', async () => {
+    const projectDir = path.join(tempDir, 'project');
+    fs.mkdirSync(projectDir, { recursive: true });
+
+    const consoleLogSpy = spyOn(console, 'log').mockImplementation(() => {});
+    try {
+      const exitCode = await runDoctorCliFrom(projectDir, { json: true });
+      const output = consoleLogSpy.mock.calls
+        .map((c) => c.join(' '))
+        .join('\n');
+
+      expect(exitCode).toBe(0);
+      const parsed = JSON.parse(output);
+      expect(parsed.ok).toBe(true);
+      expect(parsed.project).toBe(projectDir);
+      expect(parsed.configs).toHaveLength(2);
+      expect(parsed.configs[0].scope).toBe('user');
+      expect(parsed.configs[1].scope).toBe('project');
+    } finally {
+      consoleLogSpy.mockRestore();
+    }
+  });
+
+  test('JSON output has correct shape with schema error', async () => {
+    const projectDir = path.join(tempDir, 'project');
+    const configDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(configDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(configDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({ agents: { oracle: { temperature: 5 } } }),
+    );
+
+    const consoleLogSpy = spyOn(console, 'log').mockImplementation(() => {});
+    try {
+      const exitCode = await runDoctorCliFrom(projectDir, { json: true });
+      const output = consoleLogSpy.mock.calls
+        .map((c) => c.join(' '))
+        .join('\n');
+      const parsed = JSON.parse(output);
+
+      expect(exitCode).toBe(1);
+      expect(parsed.ok).toBe(false);
+      expect(parsed.configs[1].error.kind).toBe('invalid-schema');
+      expect(Array.isArray(parsed.configs[1].error.issues)).toBe(true);
+      expect(parsed.configs[1].error.issues[0].path).toContain('temperature');
+    } finally {
+      consoleLogSpy.mockRestore();
+    }
+  });
+});

+ 272 - 0
src/cli/doctor.ts

@@ -0,0 +1,272 @@
+import * as fs from 'node:fs';
+import { z } from 'zod';
+import { findPluginConfigPaths, mergePluginConfigs } from '../config/loader';
+import { type PluginConfig, PluginConfigSchema } from '../config/schema';
+import { stripJsonComments } from './config-io';
+
+export type DoctorArgs = {
+  json?: boolean;
+  error?: string;
+  help?: boolean;
+};
+
+export function parseDoctorArgs(args: string[]): DoctorArgs {
+  const result: DoctorArgs = {};
+
+  for (const arg of args) {
+    if (arg === '--json') {
+      result.json = true;
+    } else if (arg === '--help' || arg === '-h') {
+      result.help = true;
+    } else {
+      result.error = `Unknown doctor option: ${arg}`;
+    }
+  }
+
+  return result;
+}
+
+export type ConfigCheckResult = {
+  scope: 'user' | 'project';
+  path: string | null;
+  exists: boolean;
+  ok: boolean;
+  config?: PluginConfig;
+  error?: {
+    kind: 'invalid-json' | 'invalid-schema' | 'read-error';
+    message: string;
+    issues?: z.ZodIssue[];
+  };
+};
+
+export type PresetCheckResult = {
+  preset: string;
+  ok: boolean;
+  error?: { kind: 'missing-preset'; message: string };
+};
+
+export type DoctorResult = {
+  ok: boolean;
+  project: string;
+  configs: ConfigCheckResult[];
+  presetCheck?: PresetCheckResult;
+};
+
+function checkConfigFile(
+  scope: 'user' | 'project',
+  configPath: string | null,
+): ConfigCheckResult {
+  if (configPath === null) {
+    return { scope, path: null, exists: false, ok: true };
+  }
+
+  try {
+    const stat = fs.statSync(configPath);
+
+    if (stat.size === 0) {
+      return {
+        scope,
+        path: configPath,
+        exists: true,
+        ok: false,
+        error: {
+          kind: 'invalid-json',
+          message: 'Empty file is not valid JSON',
+        },
+      };
+    }
+
+    const content = fs.readFileSync(configPath, 'utf-8');
+    const rawConfig = JSON.parse(stripJsonComments(content));
+    const parseResult = PluginConfigSchema.safeParse(rawConfig);
+
+    if (!parseResult.success) {
+      return {
+        scope,
+        path: configPath,
+        exists: true,
+        ok: false,
+        error: {
+          kind: 'invalid-schema',
+          message: z.prettifyError(parseResult.error),
+          issues: parseResult.error.issues,
+        },
+      };
+    }
+
+    return {
+      scope,
+      path: configPath,
+      exists: true,
+      ok: true,
+      config: parseResult.data,
+    };
+  } catch (err) {
+    if (err instanceof SyntaxError) {
+      return {
+        scope,
+        path: configPath,
+        exists: true,
+        ok: false,
+        error: {
+          kind: 'invalid-json',
+          message: err.message,
+        },
+      };
+    } else if (
+      err instanceof Error &&
+      'code' in err &&
+      (err as NodeJS.ErrnoException).code === 'ENOENT'
+    ) {
+      return { scope, path: null, exists: false, ok: true };
+    }
+
+    return {
+      scope,
+      path: configPath,
+      exists: true,
+      ok: false,
+      error: {
+        kind: 'read-error',
+        message: err instanceof Error ? err.message : String(err),
+      },
+    };
+  }
+}
+
+function checkPreset(
+  mergedConfig: PluginConfig,
+): PresetCheckResult | undefined {
+  const envPreset = process.env.OH_MY_OPENCODE_SLIM_PRESET;
+  const presetName = envPreset || mergedConfig.preset;
+
+  if (presetName === undefined) {
+    return undefined;
+  }
+
+  if (!mergedConfig.presets?.[presetName]) {
+    return {
+      preset: presetName,
+      ok: false,
+      error: {
+        kind: 'missing-preset',
+        message: `Preset "${presetName}" not found in config`,
+      },
+    };
+  }
+
+  return { preset: presetName, ok: true };
+}
+
+function getMergedConfig(
+  userConfig?: PluginConfig,
+  projectConfig?: PluginConfig,
+): PluginConfig {
+  return projectConfig
+    ? mergePluginConfigs(userConfig ?? {}, projectConfig)
+    : (userConfig ?? {});
+}
+
+export function runDoctorCheck(cwd: string): DoctorResult {
+  const { userConfigPath, projectConfigPath } = findPluginConfigPaths(cwd);
+
+  const userCheck = checkConfigFile('user', userConfigPath);
+  const projectCheck = checkConfigFile('project', projectConfigPath);
+
+  const configs = [userCheck, projectCheck];
+
+  const hasInvalidConfig = configs.some((c) => !c.ok);
+
+  let presetCheckResult: DoctorResult['presetCheck'] | undefined;
+  if (!hasInvalidConfig) {
+    const mergedConfig = getMergedConfig(userCheck.config, projectCheck.config);
+    presetCheckResult = checkPreset(mergedConfig);
+  }
+
+  return {
+    ok:
+      configs.every((c) => c.ok) &&
+      (!presetCheckResult || presetCheckResult.ok),
+    project: cwd,
+    configs,
+    presetCheck: presetCheckResult,
+  };
+}
+
+export function formatHumanDoctorResult(result: DoctorResult): string {
+  const lines: string[] = [];
+
+  lines.push(`Project: ${result.project}`);
+  lines.push('');
+
+  for (const config of result.configs) {
+    if (config.path === null) {
+      lines.push(`[${config.scope}] No config file found`);
+    } else {
+      const status = config.ok ? '✓' : '✗';
+      lines.push(`[${config.scope}] ${config.path} ${status}`);
+
+      if (!config.ok && config.error) {
+        if (config.error.kind === 'invalid-json') {
+          lines.push(`  Invalid JSON: ${config.error.message}`);
+        } else if (config.error.kind === 'invalid-schema') {
+          lines.push('  Schema error:');
+          for (const line of config.error.message.split('\n')) {
+            lines.push(`  ${line}`);
+          }
+        } else if (config.error.kind === 'read-error') {
+          lines.push(`  Read error: ${config.error.message}`);
+        }
+      }
+    }
+  }
+
+  if (result.presetCheck) {
+    lines.push('');
+    const status = result.presetCheck.ok ? '✓' : '✗';
+    lines.push(`[preset] ${result.presetCheck.preset} ${status}`);
+
+    if (result.presetCheck.error) {
+      lines.push(`  ${result.presetCheck.error.message}`);
+    }
+  }
+
+  return lines.join('\n');
+}
+
+export function formatJsonDoctorResult(result: DoctorResult): string {
+  return JSON.stringify(
+    {
+      ...result,
+      configs: result.configs.map(({ config: _config, ...config }) => config),
+    },
+    null,
+    2,
+  );
+}
+
+export async function doctor(args: DoctorArgs): Promise<number> {
+  if (args.help) {
+    console.log(`Usage: oh-my-opencode-slim doctor [OPTIONS]
+
+Options:
+  --json              Print diagnostics as JSON
+  -h, --help          Show this help message`);
+    return 0;
+  }
+
+  if (args.error) {
+    console.error(args.error);
+    return 1;
+  }
+
+  const result = runDoctorCheck(process.cwd());
+
+  if (args.json) {
+    console.log(formatJsonDoctorResult(result));
+  } else {
+    console.log(formatHumanDoctorResult(result));
+  }
+
+  return result.ok ? 0 : 1;
+}

+ 12 - 1
src/cli/index.ts

@@ -1,4 +1,5 @@
 #!/usr/bin/env bun
+import { doctor, parseDoctorArgs } from './doctor';
 import { install } from './install';
 import { getGeneratedPresetNames, isGeneratedPresetName } from './providers';
 import type { BooleanArg, InstallArgs } from './types';
@@ -40,7 +41,9 @@ function printHelp(): void {
   console.log(`
 oh-my-opencode-slim installer
 
-Usage: bunx oh-my-opencode-slim install [OPTIONS]
+Usage:
+  bunx oh-my-opencode-slim install [OPTIONS]
+  bunx oh-my-opencode-slim doctor [OPTIONS]
 
 Options:
   --skills=yes|no        Install recommended and bundled skills (default: yes)
@@ -50,6 +53,9 @@ Options:
   --reset                Force overwrite of existing configuration
   -h, --help             Show this help message
 
+Doctor options:
+  --json                 Print diagnostics as JSON
+
 Available presets: ${getGeneratedPresetNames().join(', ')}
 
 The installer generates OpenAI and OpenCode Go presets by default.
@@ -61,6 +67,7 @@ Examples:
   bunx oh-my-opencode-slim install --no-tui --skills=yes
   bunx oh-my-opencode-slim install --preset=opencode-go
   bunx oh-my-opencode-slim install --reset
+  bunx oh-my-opencode-slim doctor
 `);
 }
 
@@ -72,6 +79,10 @@ async function main(): Promise<void> {
     const installArgs = parseArgs(args.slice(hasSubcommand ? 1 : 0));
     const exitCode = await install(installArgs);
     process.exit(exitCode);
+  } else if (args[0] === 'doctor') {
+    const doctorArgs = parseDoctorArgs(args.slice(1));
+    const exitCode = await doctor(doctorArgs);
+    process.exit(exitCode);
   } else if (args[0] === '-h' || args[0] === '--help') {
     printHelp();
     process.exit(0);

+ 52 - 27
src/config/loader.ts

@@ -80,6 +80,55 @@ function findConfigPathInDirs(
   return null;
 }
 
+/**
+ * Find plugin config paths (user and project) for a given directory.
+ * User config uses getConfigSearchDirs() for lookup.
+ * Project config uses <directory>/.opencode/oh-my-opencode-slim.
+ *
+ * @param directory - Project directory to search for .opencode config
+ * @returns Object with userConfigPath and projectConfigPath (null if not found)
+ */
+export function findPluginConfigPaths(directory: string): {
+  userConfigPath: string | null;
+  projectConfigPath: string | null;
+} {
+  const userConfigPath = findConfigPathInDirs(
+    getConfigSearchDirs(),
+    'oh-my-opencode-slim',
+  );
+
+  const projectConfigBasePath = path.join(
+    directory,
+    '.opencode',
+    'oh-my-opencode-slim',
+  );
+
+  const projectConfigPath = findConfigPath(projectConfigBasePath);
+
+  return { userConfigPath, projectConfigPath };
+}
+
+/**
+ * Merge two plugin configs using the loader's merge rules.
+ * Project/override takes precedence over base.
+ */
+export function mergePluginConfigs(
+  base: PluginConfig,
+  override: PluginConfig,
+): PluginConfig {
+  return {
+    ...base,
+    ...override,
+    agents: deepMerge(base.agents, override.agents),
+    tmux: deepMerge(base.tmux, override.tmux),
+    multiplexer: deepMerge(base.multiplexer, override.multiplexer),
+    interview: deepMerge(base.interview, override.interview),
+    sessionManager: deepMerge(base.sessionManager, override.sessionManager),
+    fallback: deepMerge(base.fallback, override.fallback),
+    council: deepMerge(base.council, override.council),
+  };
+}
+
 /**
  * Recursively merge two objects, with override values taking precedence.
  * For nested objects, merges recursively. For arrays and primitives, override replaces base.
@@ -135,19 +184,8 @@ export function deepMerge<T extends Record<string, unknown>>(
  * @returns Merged plugin configuration (empty object if no configs found)
  */
 export function loadPluginConfig(directory: string): PluginConfig {
-  const userConfigPath = findConfigPathInDirs(
-    getConfigSearchDirs(),
-    'oh-my-opencode-slim',
-  );
-
-  const projectConfigBasePath = path.join(
-    directory,
-    '.opencode',
-    'oh-my-opencode-slim',
-  );
-
-  // Find existing config files (preferring .jsonc over .json)
-  const projectConfigPath = findConfigPath(projectConfigBasePath);
+  const { userConfigPath, projectConfigPath } =
+    findPluginConfigPaths(directory);
 
   let config: PluginConfig = userConfigPath
     ? (loadConfigFromPath(userConfigPath) ?? {})
@@ -157,20 +195,7 @@ export function loadPluginConfig(directory: string): PluginConfig {
     ? loadConfigFromPath(projectConfigPath)
     : null;
   if (projectConfig) {
-    config = {
-      ...config,
-      ...projectConfig,
-      agents: deepMerge(config.agents, projectConfig.agents),
-      tmux: deepMerge(config.tmux, projectConfig.tmux),
-      multiplexer: deepMerge(config.multiplexer, projectConfig.multiplexer),
-      interview: deepMerge(config.interview, projectConfig.interview),
-      sessionManager: deepMerge(
-        config.sessionManager,
-        projectConfig.sessionManager,
-      ),
-      fallback: deepMerge(config.fallback, projectConfig.fallback),
-      council: deepMerge(config.council, projectConfig.council),
-    };
+    config = mergePluginConfigs(config, projectConfig);
   }
 
   // Migrate legacy tmux config to multiplexer config for backward compatibility