Sfoglia il codice sorgente

feat: show invalid config status in TUI

maou-shonen 3 mesi fa
parent
commit
6f91007f7f
9 ha cambiato i file con 324 aggiunte e 15 eliminazioni
  1. 2 0
      docs/configuration.md
  2. 5 1
      src/config/index.ts
  3. 155 0
      src/config/loader.test.ts
  4. 77 8
      src/config/loader.ts
  5. 13 2
      src/index.ts
  6. 31 0
      src/tui-state.test.ts
  7. 10 0
      src/tui-state.ts
  8. 8 4
      src/tui.test.ts
  9. 23 0
      src/tui.ts

+ 2 - 0
docs/configuration.md

@@ -15,6 +15,8 @@ Complete reference for all configuration files and options in oh-my-opencode-sli
 
 > **💡 JSONC recommended:** Use the `.jsonc` extension to add comments and trailing commas. If both `.jsonc` and `.json` exist, `.jsonc` takes precedence.
 
+If OMO-Slim detects an invalid plugin config during OpenCode startup, it falls back to defaults and shows a warning in the TUI sidebar. Run `oh-my-opencode-slim doctor` from your project root for full diagnostics.
+
 ---
 
 ## Prompt Overriding

+ 5 - 1
src/config/index.ts

@@ -1,5 +1,9 @@
 export * from './constants';
 export * from './council-schema';
-export { deepMerge, loadAgentPrompt, loadPluginConfig } from './loader';
+export {
+  deepMerge,
+  loadAgentPrompt,
+  loadPluginConfig,
+} from './loader';
 export * from './schema';
 export { getAgentOverride, getCustomAgentNames } from './utils';

+ 155 - 0
src/config/loader.test.ts

@@ -2,6 +2,7 @@ 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 type { ConfigLoadWarning } from './loader';
 import { loadAgentPrompt, loadPluginConfig } from './loader';
 
 // Test deepMerge indirectly through loadPluginConfig behavior
@@ -239,6 +240,160 @@ describe('loadPluginConfig', () => {
   });
 });
 
+describe('onWarning callback', () => {
+  let tempDir: string;
+  let originalEnv: typeof process.env;
+
+  beforeEach(() => {
+    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'onwarning-test-'));
+    originalEnv = { ...process.env };
+    delete process.env.OPENCODE_CONFIG_DIR;
+    process.env.XDG_CONFIG_HOME = tempDir;
+  });
+
+  afterEach(() => {
+    fs.rmSync(tempDir, { recursive: true, force: true });
+    process.env = originalEnv;
+  });
+
+  test('invalid schema calls onWarning with invalid-schema', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({ agents: { oracle: { temperature: 5 } } }),
+    );
+
+    const warnings: ConfigLoadWarning[] = [];
+    const config = loadPluginConfig(projectDir, {
+      onWarning: (warning) => warnings.push(warning),
+    });
+
+    expect(warnings).toHaveLength(1);
+    expect(warnings[0]?.kind).toBe('invalid-schema');
+    expect(warnings[0]?.path).toBe(
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
+    );
+    expect(warnings[0]?.message).toBe('Config does not match schema');
+    expect(config).toEqual({});
+  });
+
+  test('invalid JSON calls onWarning with invalid-json', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
+      '{ invalid json }',
+    );
+
+    const warnings: ConfigLoadWarning[] = [];
+    const config = loadPluginConfig(projectDir, {
+      onWarning: (warning) => warnings.push(warning),
+    });
+
+    expect(warnings).toHaveLength(1);
+    expect(warnings[0]?.kind).toBe('invalid-json');
+    expect(warnings[0]?.path).toBe(
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
+    );
+    expect(config).toEqual({});
+  });
+
+  test('read error calls onWarning with read-error', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    const configPath = path.join(projectConfigDir, 'oh-my-opencode-slim.json');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
+    fs.writeFileSync(configPath, JSON.stringify({}));
+
+    const originalReadFileSync = fs.readFileSync;
+    const readSpy = spyOn(fs, 'readFileSync').mockImplementation(((
+      ...args: Parameters<typeof fs.readFileSync>
+    ) => {
+      const [filePath] = args;
+      if (filePath === configPath) {
+        const error = new Error('Permission denied') as NodeJS.ErrnoException;
+        error.code = 'EACCES';
+        throw error;
+      }
+
+      return originalReadFileSync(...args);
+    }) as typeof fs.readFileSync);
+
+    try {
+      const warnings: ConfigLoadWarning[] = [];
+      const config = loadPluginConfig(projectDir, {
+        onWarning: (warning) => warnings.push(warning),
+      });
+
+      expect(warnings).toHaveLength(1);
+      expect(warnings[0]?.kind).toBe('read-error');
+      expect(warnings[0]?.path).toBe(configPath);
+      expect(warnings[0]?.message).toBe('Permission denied');
+      expect(config).toEqual({});
+    } finally {
+      readSpy.mockRestore();
+    }
+  });
+
+  test('missing preset calls onWarning with missing-preset', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({
+        preset: 'nonexistent',
+        presets: { other: { oracle: { model: 'other' } } },
+        agents: { oracle: { model: 'root' } },
+      }),
+    );
+
+    const warnings: ConfigLoadWarning[] = [];
+    const config = loadPluginConfig(projectDir, {
+      onWarning: (warning) => warnings.push(warning),
+    });
+
+    expect(warnings).toHaveLength(1);
+    expect(warnings[0]?.kind).toBe('missing-preset');
+    expect(warnings[0]?.message).toContain('Preset "nonexistent" not found');
+    expect(config.agents?.oracle?.model).toBe('root');
+  });
+
+  test('valid config does not call onWarning', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({ agents: { oracle: { model: 'valid/model' } } }),
+    );
+
+    const warnings: ConfigLoadWarning[] = [];
+    const config = loadPluginConfig(projectDir, {
+      onWarning: (warning) => warnings.push(warning),
+    });
+
+    expect(warnings).toHaveLength(0);
+    expect(config.agents?.oracle?.model).toBe('valid/model');
+  });
+
+  test('no options object does not break loadPluginConfig', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({ agents: { oracle: { model: 'model' } } }),
+    );
+
+    const config = loadPluginConfig(projectDir);
+    expect(config.agents?.oracle?.model).toBe('model');
+  });
+});
+
 describe('deepMerge behavior', () => {
   let tempDir: string;
   let userConfigDir: string;

+ 77 - 8
src/config/loader.ts

@@ -4,6 +4,36 @@ import { stripJsonComments } from '../cli/config-io';
 import { getConfigSearchDirs } from '../cli/paths';
 import { type PluginConfig, PluginConfigSchema } from './schema';
 
+/**
+ * Warning kinds produced during config loading.
+ */
+export type ConfigLoadWarningKind =
+  | 'invalid-json'
+  | 'invalid-schema'
+  | 'read-error'
+  | 'missing-preset';
+
+/**
+ * A warning emitted while loading plugin configuration.
+ */
+export interface ConfigLoadWarning {
+  path: string;
+  kind: ConfigLoadWarningKind;
+  message: string;
+  formatted?: unknown;
+}
+
+/**
+ * Options for loadPluginConfig.
+ */
+export interface LoadPluginConfigOptions {
+  /**
+   * Called with a warning whenever config loading produces a non-fatal issue.
+   * The loader still falls back to defaults and continues normally.
+   */
+  onWarning?: (warning: ConfigLoadWarning) => void;
+}
+
 const PROMPTS_DIR_NAME = 'oh-my-opencode-slim';
 
 /**
@@ -13,16 +43,42 @@ const PROMPTS_DIR_NAME = 'oh-my-opencode-slim';
  * Logs warnings for validation errors and unexpected read errors.
  *
  * @param configPath - Absolute path to the config file
+ * @param onWarning - Optional callback for warnings
  * @returns Validated config object, or null if loading failed
  */
-function loadConfigFromPath(configPath: string): PluginConfig | null {
+function loadConfigFromPath(
+  configPath: string,
+  onWarning?: (warning: ConfigLoadWarning) => void,
+): PluginConfig | null {
   try {
     const content = fs.readFileSync(configPath, 'utf-8');
     // Use stripJsonComments to support JSONC format (comments and trailing commas)
-    const rawConfig = JSON.parse(stripJsonComments(content));
+    let rawConfig: unknown;
+    try {
+      rawConfig = JSON.parse(stripJsonComments(content));
+    } catch (error) {
+      // Empty file or JSON parse error is treated as invalid-json
+      const message = error instanceof Error ? error.message : String(error);
+      onWarning?.({
+        path: configPath,
+        kind: 'invalid-json',
+        message,
+      });
+      console.warn(
+        `[oh-my-opencode-slim] Invalid JSON in ${configPath}:`,
+        message,
+      );
+      return null;
+    }
     const result = PluginConfigSchema.safeParse(rawConfig);
 
     if (!result.success) {
+      onWarning?.({
+        path: configPath,
+        kind: 'invalid-schema',
+        message: 'Config does not match schema',
+        formatted: result.error.format(),
+      });
       console.warn(`[oh-my-opencode-slim] Invalid config at ${configPath}:`);
       console.warn(result.error.format());
       return null;
@@ -36,6 +92,11 @@ function loadConfigFromPath(configPath: string): PluginConfig | null {
       'code' in error &&
       (error as NodeJS.ErrnoException).code !== 'ENOENT'
     ) {
+      onWarning?.({
+        path: configPath,
+        kind: 'read-error',
+        message: error.message,
+      });
       console.warn(
         `[oh-my-opencode-slim] Error reading config from ${configPath}:`,
         error.message,
@@ -182,18 +243,22 @@ export function deepMerge<T extends Record<string, unknown>>(
  * deep-merged, while top-level arrays are replaced entirely by project config.
  *
  * @param directory - Project directory to search for .opencode config
+ * @param options - Optional load options including onWarning callback
  * @returns Merged plugin configuration (empty object if no configs found)
  */
-export function loadPluginConfig(directory: string): PluginConfig {
+export function loadPluginConfig(
+  directory: string,
+  options?: LoadPluginConfigOptions,
+): PluginConfig {
   const { userConfigPath, projectConfigPath } =
     findPluginConfigPaths(directory);
 
   let config: PluginConfig = userConfigPath
-    ? (loadConfigFromPath(userConfigPath) ?? {})
+    ? (loadConfigFromPath(userConfigPath, options?.onWarning) ?? {})
     : {};
 
   const projectConfig = projectConfigPath
-    ? loadConfigFromPath(projectConfigPath)
+    ? loadConfigFromPath(projectConfigPath, options?.onWarning)
     : null;
   if (projectConfig) {
     config = mergePluginConfigs(config, projectConfig);
@@ -221,9 +286,13 @@ export function loadPluginConfig(directory: string): PluginConfig {
       const availablePresets = config.presets
         ? Object.keys(config.presets).join(', ')
         : 'none';
-      console.warn(
-        `[oh-my-opencode-slim] Preset "${config.preset}" not found (from ${presetSource}). Available presets: ${availablePresets}`,
-      );
+      const message = `Preset "${config.preset}" not found (from ${presetSource}). Available presets: ${availablePresets}`;
+      options?.onWarning?.({
+        path: projectConfigPath ?? userConfigPath ?? '',
+        kind: 'missing-preset',
+        message,
+      });
+      console.warn(`[oh-my-opencode-slim] ${message}`);
     }
   }
 

+ 13 - 2
src/index.ts

@@ -44,7 +44,11 @@ import {
   createPresetManager,
   createWebfetchTool,
 } from './tools';
-import { recordTuiAgentModel, recordTuiAgentModels } from './tui-state';
+import {
+  recordTuiAgentModel,
+  recordTuiAgentModels,
+  recordTuiConfigStatus,
+} from './tui-state';
 import {
   createDisplayNameMentionRewriter,
   resolveRuntimeAgentName,
@@ -147,7 +151,14 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
   let toolCount = 0;
 
   try {
-    config = loadPluginConfig(ctx.directory);
+    let configInvalid = false;
+
+    config = loadPluginConfig(ctx.directory, {
+      onWarning: () => {
+        configInvalid = true;
+      },
+    });
+    recordTuiConfigStatus({ invalid: configInvalid });
 
     // Safety net: if a runtime preset was set via /preset command and
     // OpenCode ever fully re-runs the plugin function (not just the

+ 31 - 0
src/tui-state.test.ts

@@ -6,6 +6,7 @@ import {
   readTuiSnapshot,
   recordTuiAgentModel,
   recordTuiAgentModels,
+  recordTuiConfigStatus,
 } from './tui-state';
 
 let previousXdgDataHome: string | undefined;
@@ -62,4 +63,34 @@ describe('tui-state persistence', () => {
       explorer: 'openai/gpt-5.4-mini',
     });
   });
+
+  test('recordTuiConfigStatus sets configInvalid to true', () => {
+    recordTuiConfigStatus({ invalid: true });
+    expect(readTuiSnapshot().configInvalid).toBe(true);
+  });
+
+  test('recordTuiConfigStatus sets configInvalid to false', () => {
+    recordTuiConfigStatus({ invalid: true });
+    recordTuiConfigStatus({ invalid: false });
+    expect(readTuiSnapshot().configInvalid).toBe(false);
+  });
+
+  test('configInvalid defaults to false for old snapshots without the field', () => {
+    // Use suite-level tempDir; write old-format snapshot (no configInvalid field)
+    const filePath = path.join(
+      tempDir,
+      'opencode',
+      'storage',
+      'oh-my-opencode-slim',
+      'tui-state.json',
+    );
+    fs.mkdirSync(path.dirname(filePath), { recursive: true });
+    fs.writeFileSync(
+      filePath,
+      JSON.stringify({ version: 1, updatedAt: Date.now(), agentModels: {} }),
+    );
+
+    const snapshot = readTuiSnapshot();
+    expect(snapshot.configInvalid).toBe(false);
+  });
 });

+ 10 - 0
src/tui-state.ts

@@ -6,6 +6,7 @@ export interface TuiSnapshot {
   version: 1;
   updatedAt: number;
   agentModels: Record<string, string>;
+  configInvalid: boolean;
 }
 
 const STATE_DIR = 'oh-my-opencode-slim';
@@ -26,6 +27,7 @@ function emptySnapshot(): TuiSnapshot {
     version: 1,
     updatedAt: Date.now(),
     agentModels: {},
+    configInvalid: false,
   };
 }
 
@@ -38,6 +40,8 @@ function parseSnapshot(value: string): TuiSnapshot {
     updatedAt:
       typeof parsed.updatedAt === 'number' ? parsed.updatedAt : Date.now(),
     agentModels: parsed.agentModels ?? {},
+    configInvalid:
+      typeof parsed.configInvalid === 'boolean' ? parsed.configInvalid : false,
   };
 }
 
@@ -90,3 +94,9 @@ export function recordTuiAgentModel(input: {
     snapshot.agentModels[input.agentName] = input.model;
   });
 }
+
+export function recordTuiConfigStatus(input: { invalid: boolean }): void {
+  updateSnapshot((snapshot) => {
+    snapshot.configInvalid = input.invalid;
+  });
+}

+ 8 - 4
src/tui.test.ts

@@ -2,11 +2,13 @@ import { describe, expect, test } from 'bun:test';
 import { formatSidebarModelName, getSidebarAgentNames } from './tui';
 import type { TuiSnapshot } from './tui-state';
 
-function createSnapshot(agentModels: TuiSnapshot['agentModels']): TuiSnapshot {
+function createSnapshot(overrides: Partial<TuiSnapshot> = {}): TuiSnapshot {
   return {
     version: 1,
     updatedAt: 0,
-    agentModels,
+    agentModels: {},
+    configInvalid: false,
+    ...overrides,
   };
 }
 
@@ -14,8 +16,10 @@ describe('tui sidebar agents', () => {
   test('hides disabled agents when models are persisted explicitly', () => {
     const agentNames = getSidebarAgentNames(
       createSnapshot({
-        explorer: 'openai/gpt-5.4-mini',
-        fixer: 'openai/gpt-5.4-mini',
+        agentModels: {
+          explorer: 'openai/gpt-5.4-mini',
+          fixer: 'openai/gpt-5.4-mini',
+        },
       }),
     );
 

+ 23 - 0
src/tui.ts

@@ -102,6 +102,8 @@ function renderSidebar(
     textMuted: unknown;
   },
 ): JSX.Element {
+  const configStatusRow = buildConfigStatusRow(snapshot, theme);
+
   return box(
     {
       width: '100%',
@@ -129,6 +131,7 @@ function renderSidebar(
           text({ fg: theme.textMuted }, [`v${version}`]),
         ],
       ),
+      configStatusRow,
       box({ width: '100%', marginTop: 1 }, [
         text({ fg: theme.text }, ['Agents']),
       ]),
@@ -145,6 +148,26 @@ function renderSidebar(
   );
 }
 
+function buildConfigStatusRow(
+  snapshot: TuiSnapshot,
+  theme: { textMuted: unknown },
+): JSX.Element | null {
+  if (!snapshot.configInvalid) return null;
+
+  return box(
+    {
+      width: '100%',
+      flexDirection: 'column',
+      marginTop: 1,
+      marginBottom: 1,
+    },
+    [
+      text({ fg: 'yellow' }, ['Config invalid']),
+      text({ fg: theme.textMuted }, ['Run doctor for details']),
+    ],
+  );
+}
+
 const plugin: TuiPluginModule & { id: string } = {
   id: `${PLUGIN_NAME}:tui`,
   tui: async (api, _options, meta) => {