loader.ts 3.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
  1. import * as fs from "fs";
  2. import * as path from "path";
  3. import * as os from "os";
  4. import { PluginConfigSchema, type PluginConfig } from "./schema";
  5. const CONFIG_FILENAME = "oh-my-opencode-slim.json";
  6. /**
  7. * Get the user's configuration directory following XDG Base Directory specification.
  8. * Falls back to ~/.config if XDG_CONFIG_HOME is not set.
  9. *
  10. * @returns The absolute path to the user's config directory
  11. */
  12. function getUserConfigDir(): string {
  13. return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
  14. }
  15. /**
  16. * Load and validate plugin configuration from a specific file path.
  17. * Returns null if the file doesn't exist, is invalid, or cannot be read.
  18. * Logs warnings for validation errors and unexpected read errors.
  19. *
  20. * @param configPath - Absolute path to the config file
  21. * @returns Validated config object, or null if loading failed
  22. */
  23. function loadConfigFromPath(configPath: string): PluginConfig | null {
  24. try {
  25. const content = fs.readFileSync(configPath, "utf-8");
  26. const rawConfig = JSON.parse(content);
  27. const result = PluginConfigSchema.safeParse(rawConfig);
  28. if (!result.success) {
  29. console.warn(`[oh-my-opencode-slim] Invalid config at ${configPath}:`);
  30. console.warn(result.error.format());
  31. return null;
  32. }
  33. return result.data;
  34. } catch (error) {
  35. // File doesn't exist or isn't readable - this is expected and fine
  36. if (error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code !== 'ENOENT') {
  37. console.warn(`[oh-my-opencode-slim] Error reading config from ${configPath}:`, error.message);
  38. }
  39. return null;
  40. }
  41. }
  42. /**
  43. * Recursively merge two objects, with override values taking precedence.
  44. * For nested objects, merges recursively. For arrays and primitives, override replaces base.
  45. *
  46. * @param base - Base object to merge into
  47. * @param override - Override object whose values take precedence
  48. * @returns Merged object, or undefined if both inputs are undefined
  49. */
  50. function deepMerge<T extends Record<string, unknown>>(base?: T, override?: T): T | undefined {
  51. if (!base) return override;
  52. if (!override) return base;
  53. const result = { ...base } as T;
  54. for (const key of Object.keys(override) as (keyof T)[]) {
  55. const baseVal = base[key];
  56. const overrideVal = override[key];
  57. if (
  58. typeof baseVal === "object" && baseVal !== null &&
  59. typeof overrideVal === "object" && overrideVal !== null &&
  60. !Array.isArray(baseVal) && !Array.isArray(overrideVal)
  61. ) {
  62. result[key] = deepMerge(
  63. baseVal as Record<string, unknown>,
  64. overrideVal as Record<string, unknown>
  65. ) as T[keyof T];
  66. } else {
  67. result[key] = overrideVal;
  68. }
  69. }
  70. return result;
  71. }
  72. /**
  73. * Load plugin configuration from user and project config files, merging them appropriately.
  74. *
  75. * Configuration is loaded from two locations:
  76. * 1. User config: ~/.config/opencode/oh-my-opencode-slim.json (or $XDG_CONFIG_HOME)
  77. * 2. Project config: <directory>/.opencode/oh-my-opencode-slim.json
  78. *
  79. * Project config takes precedence over user config. Nested objects (agents, tmux) are
  80. * deep-merged, while top-level arrays are replaced entirely by project config.
  81. *
  82. * @param directory - Project directory to search for .opencode config
  83. * @returns Merged plugin configuration (empty object if no configs found)
  84. */
  85. export function loadPluginConfig(directory: string): PluginConfig {
  86. const userConfigPath = path.join(
  87. getUserConfigDir(),
  88. "opencode",
  89. CONFIG_FILENAME
  90. );
  91. const projectConfigPath = path.join(directory, ".opencode", CONFIG_FILENAME);
  92. let config: PluginConfig = loadConfigFromPath(userConfigPath) ?? {};
  93. const projectConfig = loadConfigFromPath(projectConfigPath);
  94. if (projectConfig) {
  95. config = {
  96. ...config,
  97. ...projectConfig,
  98. agents: deepMerge(config.agents, projectConfig.agents),
  99. tmux: deepMerge(config.tmux, projectConfig.tmux),
  100. };
  101. }
  102. return config;
  103. }