2026-01-02 11:35:56 +09:00
|
|
|
import * as fs from "fs";
|
2026-05-04 19:25:07 +09:00
|
|
|
import { homedir } from "node:os";
|
2026-01-02 11:35:56 +09:00
|
|
|
import * as path from "path";
|
2026-03-02 23:55:48 +09:00
|
|
|
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "./config";
|
2026-01-02 11:35:56 +09:00
|
|
|
import {
|
|
|
|
|
log,
|
2026-05-04 19:36:23 +09:00
|
|
|
containsPath,
|
2026-01-02 11:35:56 +09:00
|
|
|
deepMerge,
|
2026-01-22 12:15:09 +07:00
|
|
|
getOpenCodeConfigDir,
|
2026-01-02 11:35:56 +09:00
|
|
|
addConfigLoadError,
|
|
|
|
|
parseJsonc,
|
2026-03-23 18:11:01 +09:00
|
|
|
detectPluginConfigFile,
|
2026-05-04 19:25:07 +09:00
|
|
|
findProjectOpencodePluginConfigFiles,
|
2026-01-02 11:35:56 +09:00
|
|
|
migrateConfigFile,
|
2026-04-14 13:03:07 -04:00
|
|
|
resolveAgentDefinitionPaths,
|
2026-01-02 11:35:56 +09:00
|
|
|
} from "./shared";
|
2026-03-27 15:40:04 +09:00
|
|
|
import { migrateLegacyConfigFile } from "./shared/migrate-legacy-config-file";
|
2026-04-04 14:33:52 +09:00
|
|
|
import { CONFIG_BASENAME, LEGACY_CONFIG_BASENAME } from "./shared/plugin-identity";
|
2026-01-02 11:35:56 +09:00
|
|
|
|
2026-05-04 19:25:07 +09:00
|
|
|
function resolveHomeDirectory(): string {
|
|
|
|
|
// Read env vars directly to bypass os.homedir() caching. Bun caches the
|
|
|
|
|
// first os.homedir() result, which means tests that set process.env.HOME
|
|
|
|
|
// after import never see the new value. Production behaviour is preserved
|
|
|
|
|
// because HOME (or USERPROFILE on Windows) is set by the OS at startup.
|
|
|
|
|
return process.env.HOME ?? process.env.USERPROFILE ?? homedir()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-04 19:36:23 +09:00
|
|
|
function resolveConfigPathAfterLegacyMigration(detectedPath: string): string {
|
2026-05-04 19:25:07 +09:00
|
|
|
if (!path.basename(detectedPath).startsWith(LEGACY_CONFIG_BASENAME)) {
|
|
|
|
|
return detectedPath
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const migrated = migrateLegacyConfigFile(detectedPath)
|
|
|
|
|
const canonicalPath = path.join(
|
|
|
|
|
path.dirname(detectedPath),
|
|
|
|
|
`${CONFIG_BASENAME}${path.extname(detectedPath)}`,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Only switch to canonical path if migration succeeded OR canonical file already exists
|
|
|
|
|
if (migrated || fs.existsSync(canonicalPath)) {
|
|
|
|
|
return canonicalPath
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Otherwise keep loading from the legacy path that was detected
|
|
|
|
|
return detectedPath
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 18:18:11 +09:00
|
|
|
function loadExplicitGitMasterOverrides(configPath: string): Record<string, unknown> | undefined {
|
|
|
|
|
try {
|
|
|
|
|
if (!fs.existsSync(configPath)) {
|
|
|
|
|
return undefined
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const content = fs.readFileSync(configPath, "utf-8")
|
|
|
|
|
const rawConfig = parseJsonc<Record<string, unknown>>(content)
|
|
|
|
|
const gitMaster = rawConfig.git_master
|
|
|
|
|
|
|
|
|
|
if (gitMaster && typeof gitMaster === "object" && !Array.isArray(gitMaster)) {
|
|
|
|
|
return gitMaster as Record<string, unknown>
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
return undefined
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return undefined
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-12 01:07:43 +09:00
|
|
|
const PARTIAL_STRING_ARRAY_KEYS = new Set([
|
|
|
|
|
"disabled_mcps",
|
|
|
|
|
"disabled_agents",
|
|
|
|
|
"disabled_skills",
|
|
|
|
|
"disabled_hooks",
|
|
|
|
|
"disabled_commands",
|
|
|
|
|
"disabled_tools",
|
2026-04-02 14:55:01 +09:00
|
|
|
"mcp_env_allowlist",
|
2026-04-14 13:03:07 -04:00
|
|
|
"agent_definitions",
|
2026-03-12 01:07:43 +09:00
|
|
|
]);
|
|
|
|
|
|
2026-02-12 01:33:12 +05:30
|
|
|
export function parseConfigPartially(
|
|
|
|
|
rawConfig: Record<string, unknown>
|
|
|
|
|
): OhMyOpenCodeConfig | null {
|
|
|
|
|
const fullResult = OhMyOpenCodeConfigSchema.safeParse(rawConfig);
|
|
|
|
|
if (fullResult.success) {
|
|
|
|
|
return fullResult.data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const partialConfig: Record<string, unknown> = {};
|
|
|
|
|
const invalidSections: string[] = [];
|
|
|
|
|
|
|
|
|
|
for (const key of Object.keys(rawConfig)) {
|
2026-03-12 01:07:43 +09:00
|
|
|
if (PARTIAL_STRING_ARRAY_KEYS.has(key)) {
|
|
|
|
|
const sectionValue = rawConfig[key];
|
|
|
|
|
if (Array.isArray(sectionValue) && sectionValue.every((value) => typeof value === "string")) {
|
|
|
|
|
partialConfig[key] = sectionValue;
|
|
|
|
|
}
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 01:33:12 +05:30
|
|
|
const sectionResult = OhMyOpenCodeConfigSchema.safeParse({ [key]: rawConfig[key] });
|
|
|
|
|
if (sectionResult.success) {
|
|
|
|
|
const parsed = sectionResult.data as Record<string, unknown>;
|
|
|
|
|
if (parsed[key] !== undefined) {
|
|
|
|
|
partialConfig[key] = parsed[key];
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
const sectionErrors = sectionResult.error.issues
|
|
|
|
|
.filter((i) => i.path[0] === key)
|
|
|
|
|
.map((i) => `${i.path.join(".")}: ${i.message}`)
|
|
|
|
|
.join(", ");
|
|
|
|
|
if (sectionErrors) {
|
|
|
|
|
invalidSections.push(`${key}: ${sectionErrors}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (invalidSections.length > 0) {
|
2026-04-04 01:27:51 +09:00
|
|
|
log("Partial config loaded - invalid sections skipped:", invalidSections);
|
2026-02-12 01:33:12 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return partialConfig as OhMyOpenCodeConfig;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-02 11:35:56 +09:00
|
|
|
export function loadConfigFromPath(
|
|
|
|
|
configPath: string,
|
2026-02-16 22:12:21 +09:00
|
|
|
_ctx: unknown
|
2026-01-02 11:35:56 +09:00
|
|
|
): OhMyOpenCodeConfig | null {
|
|
|
|
|
try {
|
|
|
|
|
if (fs.existsSync(configPath)) {
|
|
|
|
|
const content = fs.readFileSync(configPath, "utf-8");
|
|
|
|
|
const rawConfig = parseJsonc<Record<string, unknown>>(content);
|
|
|
|
|
|
|
|
|
|
migrateConfigFile(configPath, rawConfig);
|
|
|
|
|
|
|
|
|
|
const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig);
|
|
|
|
|
|
2026-02-12 01:33:12 +05:30
|
|
|
if (result.success) {
|
|
|
|
|
log(`Config loaded from ${configPath}`, { agents: result.data.agents });
|
|
|
|
|
return result.data;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const errorMsg = result.error.issues
|
|
|
|
|
.map((i) => `${i.path.join(".")}: ${i.message}`)
|
|
|
|
|
.join(", ");
|
|
|
|
|
log(`Config validation error in ${configPath}:`, result.error.issues);
|
|
|
|
|
addConfigLoadError({
|
|
|
|
|
path: configPath,
|
2026-04-04 01:27:51 +09:00
|
|
|
error: `Partial config loaded - invalid sections skipped: ${errorMsg}`,
|
2026-02-12 01:33:12 +05:30
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const partialResult = parseConfigPartially(rawConfig);
|
|
|
|
|
if (partialResult) {
|
|
|
|
|
log(`Partial config loaded from ${configPath}`, { agents: partialResult.agents });
|
|
|
|
|
return partialResult;
|
2026-01-02 11:35:56 +09:00
|
|
|
}
|
|
|
|
|
|
2026-02-12 01:33:12 +05:30
|
|
|
return null;
|
2026-01-02 11:35:56 +09:00
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
|
|
|
log(`Error loading config from ${configPath}:`, err);
|
|
|
|
|
addConfigLoadError({ path: configPath, error: errorMsg });
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function mergeConfigs(
|
|
|
|
|
base: OhMyOpenCodeConfig,
|
|
|
|
|
override: OhMyOpenCodeConfig
|
|
|
|
|
): OhMyOpenCodeConfig {
|
|
|
|
|
return {
|
|
|
|
|
...base,
|
|
|
|
|
...override,
|
|
|
|
|
agents: deepMerge(base.agents, override.agents),
|
2026-01-13 22:48:13 +09:00
|
|
|
categories: deepMerge(base.categories, override.categories),
|
2026-04-28 10:44:53 +09:00
|
|
|
team_mode: deepMerge(base.team_mode, override.team_mode),
|
2026-04-14 13:03:07 -04:00
|
|
|
agent_definitions: [
|
|
|
|
|
...new Set([
|
|
|
|
|
...(base.agent_definitions ?? []),
|
|
|
|
|
...(override.agent_definitions ?? []),
|
|
|
|
|
]),
|
|
|
|
|
],
|
2026-01-02 11:35:56 +09:00
|
|
|
disabled_agents: [
|
|
|
|
|
...new Set([
|
|
|
|
|
...(base.disabled_agents ?? []),
|
|
|
|
|
...(override.disabled_agents ?? []),
|
|
|
|
|
]),
|
|
|
|
|
],
|
|
|
|
|
disabled_mcps: [
|
|
|
|
|
...new Set([
|
|
|
|
|
...(base.disabled_mcps ?? []),
|
|
|
|
|
...(override.disabled_mcps ?? []),
|
|
|
|
|
]),
|
|
|
|
|
],
|
|
|
|
|
disabled_hooks: [
|
|
|
|
|
...new Set([
|
|
|
|
|
...(base.disabled_hooks ?? []),
|
|
|
|
|
...(override.disabled_hooks ?? []),
|
|
|
|
|
]),
|
|
|
|
|
],
|
|
|
|
|
disabled_commands: [
|
|
|
|
|
...new Set([
|
|
|
|
|
...(base.disabled_commands ?? []),
|
|
|
|
|
...(override.disabled_commands ?? []),
|
|
|
|
|
]),
|
|
|
|
|
],
|
|
|
|
|
disabled_skills: [
|
|
|
|
|
...new Set([
|
|
|
|
|
...(base.disabled_skills ?? []),
|
|
|
|
|
...(override.disabled_skills ?? []),
|
|
|
|
|
]),
|
|
|
|
|
],
|
2026-03-13 21:35:46 +09:00
|
|
|
disabled_tools: [
|
|
|
|
|
...new Set([
|
|
|
|
|
...(base.disabled_tools ?? []),
|
|
|
|
|
...(override.disabled_tools ?? []),
|
|
|
|
|
]),
|
|
|
|
|
],
|
2026-04-02 14:55:01 +09:00
|
|
|
mcp_env_allowlist: [
|
|
|
|
|
...new Set([
|
|
|
|
|
...(base.mcp_env_allowlist ?? []),
|
|
|
|
|
...(override.mcp_env_allowlist ?? []),
|
|
|
|
|
]),
|
|
|
|
|
],
|
2026-01-02 11:35:56 +09:00
|
|
|
claude_code: deepMerge(base.claude_code, override.claude_code),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function loadPluginConfig(
|
|
|
|
|
directory: string,
|
|
|
|
|
ctx: unknown
|
|
|
|
|
): OhMyOpenCodeConfig {
|
2026-01-22 12:15:09 +07:00
|
|
|
// User-level config path - prefer .jsonc over .json
|
|
|
|
|
const configDir = getOpenCodeConfigDir({ binary: "opencode" });
|
2026-03-23 18:11:01 +09:00
|
|
|
const userDetected = detectPluginConfigFile(configDir);
|
2026-04-04 14:33:52 +09:00
|
|
|
let userConfigPath =
|
2026-01-02 11:35:56 +09:00
|
|
|
userDetected.format !== "none"
|
|
|
|
|
? userDetected.path
|
2026-04-05 14:26:54 +09:00
|
|
|
: path.join(configDir, `${CONFIG_BASENAME}.json`);
|
2026-01-02 11:35:56 +09:00
|
|
|
|
2026-04-03 17:15:08 +09:00
|
|
|
if (userDetected.legacyPath) {
|
|
|
|
|
log("Canonical plugin config detected alongside legacy config. Remove the legacy file to avoid confusion.", {
|
|
|
|
|
canonicalPath: userDetected.path,
|
|
|
|
|
legacyPath: userDetected.legacyPath,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-27 15:40:04 +09:00
|
|
|
// Auto-copy legacy config file to canonical name if needed
|
2026-05-04 19:25:07 +09:00
|
|
|
if (userDetected.format !== "none") {
|
2026-05-04 19:36:23 +09:00
|
|
|
userConfigPath = resolveConfigPathAfterLegacyMigration(userConfigPath)
|
2026-04-03 17:15:08 +09:00
|
|
|
}
|
|
|
|
|
|
2026-05-04 19:36:23 +09:00
|
|
|
// Pin the walk to $HOME only when the start directory is inside it. Outside
|
|
|
|
|
// $HOME the walker would otherwise reach FS root and surface unrelated configs
|
|
|
|
|
// in /tmp, /opt, etc.
|
|
|
|
|
const homeDirectory = resolveHomeDirectory()
|
|
|
|
|
const stopDirectory = containsPath(homeDirectory, directory) ? homeDirectory : directory
|
|
|
|
|
const ancestorConfigPathsNearestFirst = findProjectOpencodePluginConfigFiles(
|
2026-05-04 19:25:07 +09:00
|
|
|
directory,
|
2026-05-04 19:36:23 +09:00
|
|
|
stopDirectory,
|
2026-05-04 19:25:07 +09:00
|
|
|
)
|
|
|
|
|
log("Walked ancestor plugin configs", {
|
2026-05-04 19:36:23 +09:00
|
|
|
paths: ancestorConfigPathsNearestFirst,
|
|
|
|
|
count: ancestorConfigPathsNearestFirst.length,
|
|
|
|
|
stopDirectory,
|
2026-05-04 19:25:07 +09:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Migrate any legacy basenames among ancestors and warn on dual-config presence
|
2026-05-04 19:36:23 +09:00
|
|
|
const canonicalAncestorPathsNearestFirst = ancestorConfigPathsNearestFirst.map(
|
|
|
|
|
(ancestorPath) => {
|
|
|
|
|
const opencodeDir = path.dirname(ancestorPath)
|
|
|
|
|
const ancestorDetected = detectPluginConfigFile(opencodeDir)
|
|
|
|
|
if (ancestorDetected.legacyPath) {
|
|
|
|
|
log("Canonical plugin config detected alongside legacy config. Remove the legacy file to avoid confusion.", {
|
|
|
|
|
canonicalPath: ancestorDetected.path,
|
|
|
|
|
legacyPath: ancestorDetected.legacyPath,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
return resolveConfigPathAfterLegacyMigration(ancestorPath)
|
|
|
|
|
},
|
|
|
|
|
)
|
2026-03-27 15:40:04 +09:00
|
|
|
|
2026-03-27 21:30:56 +09:00
|
|
|
// Load user config first (base). Parse empty config through Zod to apply field defaults.
|
2026-04-03 17:45:02 +09:00
|
|
|
const userConfig = loadConfigFromPath(userConfigPath, ctx)
|
2026-04-12 18:18:11 +09:00
|
|
|
const userGitMasterOverrides = loadExplicitGitMasterOverrides(userConfigPath)
|
2026-04-14 13:03:07 -04:00
|
|
|
|
|
|
|
|
if (userConfig?.agent_definitions) {
|
|
|
|
|
userConfig.agent_definitions = resolveAgentDefinitionPaths(
|
|
|
|
|
userConfig.agent_definitions,
|
|
|
|
|
configDir,
|
|
|
|
|
null
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-02 11:35:56 +09:00
|
|
|
let config: OhMyOpenCodeConfig =
|
2026-04-03 17:45:02 +09:00
|
|
|
userConfig ?? OhMyOpenCodeConfigSchema.parse({});
|
2026-01-02 11:35:56 +09:00
|
|
|
|
2026-05-04 19:36:23 +09:00
|
|
|
const canonicalAncestorPathsFarthestFirst = [...canonicalAncestorPathsNearestFirst].reverse()
|
2026-04-12 18:18:11 +09:00
|
|
|
const defaultGitMaster = OhMyOpenCodeConfigSchema.parse({}).git_master
|
2026-05-04 19:36:23 +09:00
|
|
|
const ancestorGitMasterOverridesFarthestFirst: Array<Record<string, unknown>> = []
|
2026-05-04 19:25:07 +09:00
|
|
|
|
2026-05-04 19:36:23 +09:00
|
|
|
for (const ancestorPath of canonicalAncestorPathsFarthestFirst) {
|
2026-05-04 19:25:07 +09:00
|
|
|
const ancestorConfig = loadConfigFromPath(ancestorPath, ctx)
|
|
|
|
|
const ancestorOverrides = loadExplicitGitMasterOverrides(ancestorPath)
|
|
|
|
|
|
|
|
|
|
if (ancestorConfig?.agent_definitions) {
|
|
|
|
|
// Resolve relative paths against this ancestor's own .opencode/ base.
|
|
|
|
|
const ancestorBasePath = path.dirname(ancestorPath)
|
|
|
|
|
const ancestorDir = path.dirname(ancestorBasePath)
|
|
|
|
|
ancestorConfig.agent_definitions = resolveAgentDefinitionPaths(
|
|
|
|
|
ancestorConfig.agent_definitions,
|
|
|
|
|
ancestorBasePath,
|
|
|
|
|
ancestorDir,
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-04-14 13:03:07 -04:00
|
|
|
|
2026-05-04 19:25:07 +09:00
|
|
|
if (ancestorConfig) {
|
|
|
|
|
config = mergeConfigs(config, ancestorConfig)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (ancestorOverrides) {
|
2026-05-04 19:36:23 +09:00
|
|
|
ancestorGitMasterOverridesFarthestFirst.push(ancestorOverrides)
|
2026-05-04 19:25:07 +09:00
|
|
|
}
|
2026-01-02 11:35:56 +09:00
|
|
|
}
|
|
|
|
|
|
2026-05-04 19:36:23 +09:00
|
|
|
if (userGitMasterOverrides || ancestorGitMasterOverridesFarthestFirst.length > 0) {
|
|
|
|
|
const mergedAncestorGitMaster: Record<string, unknown> = {}
|
|
|
|
|
for (const override of ancestorGitMasterOverridesFarthestFirst) {
|
|
|
|
|
Object.assign(mergedAncestorGitMaster, override)
|
|
|
|
|
}
|
2026-04-12 18:18:11 +09:00
|
|
|
config = {
|
|
|
|
|
...config,
|
|
|
|
|
git_master: {
|
|
|
|
|
...defaultGitMaster,
|
|
|
|
|
...(userGitMasterOverrides ?? {}),
|
2026-05-04 19:36:23 +09:00
|
|
|
...mergedAncestorGitMaster,
|
2026-04-12 18:18:11 +09:00
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-04 19:25:07 +09:00
|
|
|
// Security: mcp_env_allowlist remains user-only across the entire walk.
|
|
|
|
|
// This prevents clone-and-load attacks where a malicious project (or any
|
|
|
|
|
// walked ancestor) could extend the env var allowlist used during ${VAR}
|
|
|
|
|
// expansion in .mcp.json files. See commit 316d2504 for context.
|
2026-02-01 23:48:48 +09:00
|
|
|
config = {
|
|
|
|
|
...config,
|
2026-04-03 17:45:02 +09:00
|
|
|
mcp_env_allowlist: userConfig?.mcp_env_allowlist ?? [],
|
2026-02-01 23:48:48 +09:00
|
|
|
};
|
|
|
|
|
|
2026-01-02 11:35:56 +09:00
|
|
|
log("Final merged config", {
|
|
|
|
|
agents: config.agents,
|
|
|
|
|
disabled_agents: config.disabled_agents,
|
|
|
|
|
disabled_mcps: config.disabled_mcps,
|
|
|
|
|
disabled_hooks: config.disabled_hooks,
|
|
|
|
|
claude_code: config.claude_code,
|
|
|
|
|
});
|
|
|
|
|
return config;
|
|
|
|
|
}
|