Merge pull request #3783 from matchai/feat/walk-up-config-discovery

feat(config): walk up directory tree to merge ancestor plugin configs
This commit is contained in:
YeonGyu-Kim
2026-05-06 16:37:01 +09:00
committed by GitHub
6 changed files with 555 additions and 60 deletions
+1 -1
View File
@@ -336,7 +336,7 @@ Opinionated defaults, adjustable if you insist.
See [Configuration Documentation](docs/reference/configuration.md).
**Quick Overview:**
- **Config Locations**: The compatibility layer recognizes both `oh-my-openagent.json[c]` and legacy `oh-my-opencode.json[c]` plugin config files. Existing installs still commonly use the legacy basename.
- **Config Locations**: User config plus walked `.opencode/oh-my-openagent.json[c]` configs up to `$HOME`; closest wins. Legacy `oh-my-opencode.json[c]` still works.
- **JSONC Support**: Comments and trailing commas supported
- **Agents**: Override models, temperatures, prompts, and permissions for any agent
- **Built-in Skills**: `playwright` (browser automation), `git-master` (atomic commits)
+4 -2
View File
@@ -43,9 +43,9 @@ Complete reference for Oh My OpenCode plugin configuration. During the rename tr
### File Locations
User config is loaded first, then project config overrides it. In each directory, the compatibility layer recognizes both the renamed and legacy basenames.
User config loads first. Project configs are discovered by walking from the working directory up to `$HOME`; closer configs win. If the working directory is outside `$HOME`, only that directory is checked.
1. Project config: `.opencode/oh-my-openagent.json[c]` or `.opencode/oh-my-opencode.json[c]`
1. Walked configs: `.opencode/oh-my-openagent.json[c]` or legacy `.opencode/oh-my-opencode.json[c]`
2. User config (`.jsonc` preferred over `.json`):
| Platform | Path candidates |
@@ -53,6 +53,8 @@ User config is loaded first, then project config overrides it. In each directory
| macOS/Linux | `~/.config/opencode/oh-my-openagent.json[c]`, `~/.config/opencode/oh-my-opencode.json[c]` |
| Windows | `%APPDATA%\opencode\oh-my-openagent.json[c]`, `%APPDATA%\opencode\oh-my-opencode.json[c]` |
**Security note:** `mcp_env_allowlist` is user-only. Walked configs cannot extend it.
**Rename compatibility:** The published package and CLI binary remain `oh-my-opencode`. OpenCode plugin registration prefers `oh-my-openagent`, while legacy `oh-my-opencode` entries and config basenames still load during the transition. Config detection checks `oh-my-opencode` before `oh-my-openagent`, so if both plugin config basenames exist in the same directory, the legacy `oh-my-opencode.*` file currently wins.
JSONC supports `// line comments`, `/* block comments */`, and trailing commas.
+330 -3
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, mock } from "bun:test";
import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { afterEach, describe, expect, it, mock, spyOn } from "bun:test";
import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { mergeConfigs, parseConfigPartially } from "./plugin-config";
@@ -560,7 +560,6 @@ describe("loadPluginConfig", () => {
git_env_prefix: "GIT_MASTER=1",
})
})
describe("team_mode.tmux_visualization", () => {
it("#given canonical user config enables team_mode and legacy config also exists #when loadPluginConfig runs #then tmux_visualization remains false", async () => {
// given
@@ -639,4 +638,332 @@ describe("loadPluginConfig", () => {
expect(config.team_mode).toBeUndefined()
})
})
it("should merge configs from ancestor directories with closer winning", async () => {
// given
const rootDir = mkdtempSync(join(tmpdir(), "omo-plugin-config-walk-"))
const userConfigDir = join(rootDir, "user-config")
const homeDir = join(rootDir, "home")
const workDir = join(homeDir, "work")
const projectDir = join(workDir, "project")
tempDirs.push(rootDir)
mkdirSync(userConfigDir, { recursive: true })
mkdirSync(join(homeDir, ".opencode"), { recursive: true })
mkdirSync(join(workDir, ".opencode"), { recursive: true })
mkdirSync(join(projectDir, ".opencode"), { recursive: true })
writeFileSync(
join(userConfigDir, "oh-my-openagent.jsonc"),
JSON.stringify({ agents: { oracle: { model: "user/model" } } })
)
writeFileSync(
join(homeDir, ".opencode", "oh-my-openagent.jsonc"),
JSON.stringify({ agents: { oracle: { model: "home/model" } } })
)
writeFileSync(
join(workDir, ".opencode", "oh-my-openagent.jsonc"),
JSON.stringify({ agents: { oracle: { model: "work/model" } } })
)
writeFileSync(
join(projectDir, ".opencode", "oh-my-openagent.jsonc"),
JSON.stringify({ agents: { oracle: { model: "project/model" } } })
)
process.env.OPENCODE_CONFIG_DIR = userConfigDir
process.env.HOME = homeDir
// when
const { loadPluginConfig } = await importFreshPluginConfigModule()
const config = loadPluginConfig(projectDir, {})
// then
expect(config.agents?.oracle?.model).toBe("project/model")
})
it("should layer ancestor configs so each contributes fields not overridden by closer ones", async () => {
// given
const rootDir = mkdtempSync(join(tmpdir(), "omo-plugin-config-walk-layer-"))
const userConfigDir = join(rootDir, "user-config")
const homeDir = join(rootDir, "home")
const workDir = join(homeDir, "work")
const projectDir = join(workDir, "project")
tempDirs.push(rootDir)
mkdirSync(userConfigDir, { recursive: true })
mkdirSync(join(homeDir, ".opencode"), { recursive: true })
mkdirSync(join(workDir, ".opencode"), { recursive: true })
mkdirSync(join(projectDir, ".opencode"), { recursive: true })
writeFileSync(join(userConfigDir, "oh-my-openagent.jsonc"), "{}")
writeFileSync(
join(homeDir, ".opencode", "oh-my-openagent.jsonc"),
JSON.stringify({ agents: { oracle: { model: "home/oracle" } } })
)
writeFileSync(
join(workDir, ".opencode", "oh-my-openagent.jsonc"),
JSON.stringify({ agents: { hephaestus: { model: "work/hephaestus" } } })
)
writeFileSync(
join(projectDir, ".opencode", "oh-my-openagent.jsonc"),
JSON.stringify({ agents: { sisyphus: { model: "project/sisyphus" } } })
)
process.env.OPENCODE_CONFIG_DIR = userConfigDir
process.env.HOME = homeDir
// when
const { loadPluginConfig } = await importFreshPluginConfigModule()
const config = loadPluginConfig(projectDir, {})
// then - each level contributes a non-conflicting field
expect(config.agents?.oracle?.model).toBe("home/oracle")
expect(config.agents?.hephaestus?.model).toBe("work/hephaestus")
expect(config.agents?.sisyphus?.model).toBe("project/sisyphus")
})
it("should preserve mcp_env_allowlist as user-only when ancestors set their own allowlists", async () => {
// given
const rootDir = mkdtempSync(join(tmpdir(), "omo-plugin-config-walk-allowlist-"))
const userConfigDir = join(rootDir, "user-config")
const homeDir = join(rootDir, "home")
const workDir = join(homeDir, "work")
const projectDir = join(workDir, "project")
tempDirs.push(rootDir)
mkdirSync(userConfigDir, { recursive: true })
mkdirSync(join(homeDir, ".opencode"), { recursive: true })
mkdirSync(join(workDir, ".opencode"), { recursive: true })
mkdirSync(join(projectDir, ".opencode"), { recursive: true })
writeFileSync(
join(userConfigDir, "oh-my-openagent.jsonc"),
JSON.stringify({ mcp_env_allowlist: ["USER_ONLY_TOKEN"] })
)
writeFileSync(
join(homeDir, ".opencode", "oh-my-openagent.jsonc"),
JSON.stringify({ mcp_env_allowlist: ["HOME_TOKEN"] })
)
writeFileSync(
join(workDir, ".opencode", "oh-my-openagent.jsonc"),
JSON.stringify({ mcp_env_allowlist: ["WORK_TOKEN"] })
)
writeFileSync(
join(projectDir, ".opencode", "oh-my-openagent.jsonc"),
JSON.stringify({ mcp_env_allowlist: ["PROJECT_TOKEN"] })
)
process.env.OPENCODE_CONFIG_DIR = userConfigDir
process.env.HOME = homeDir
// when
const { loadPluginConfig } = await importFreshPluginConfigModule()
const config = loadPluginConfig(projectDir, {})
// then - only the canonical user config can extend the allowlist
expect(config.mcp_env_allowlist).toEqual(["USER_ONLY_TOKEN"])
})
it("should stop walking at $HOME and ignore configs above it", async () => {
// given
const rootDir = mkdtempSync(join(tmpdir(), "omo-plugin-config-walk-stop-"))
const userConfigDir = join(rootDir, "user-config")
const aboveHomeDir = join(rootDir, "above-home")
const homeDir = join(aboveHomeDir, "home")
const projectDir = join(homeDir, "project")
tempDirs.push(rootDir)
mkdirSync(userConfigDir, { recursive: true })
mkdirSync(join(aboveHomeDir, ".opencode"), { recursive: true })
mkdirSync(join(homeDir, ".opencode"), { recursive: true })
mkdirSync(join(projectDir, ".opencode"), { recursive: true })
writeFileSync(join(userConfigDir, "oh-my-openagent.jsonc"), "{}")
writeFileSync(
join(aboveHomeDir, ".opencode", "oh-my-openagent.jsonc"),
JSON.stringify({ agents: { oracle: { model: "above-home/leak" } } })
)
writeFileSync(
join(homeDir, ".opencode", "oh-my-openagent.jsonc"),
JSON.stringify({ agents: { hephaestus: { model: "home/wins" } } })
)
writeFileSync(join(projectDir, ".opencode", "oh-my-openagent.jsonc"), "{}")
process.env.OPENCODE_CONFIG_DIR = userConfigDir
process.env.HOME = homeDir
// when
const { loadPluginConfig } = await importFreshPluginConfigModule()
const config = loadPluginConfig(projectDir, {})
// then - $HOME's config applies, but the directory above it does NOT
expect(config.agents?.hephaestus?.model).toBe("home/wins")
expect(config.agents?.oracle).toBeUndefined()
})
it("should not walk above the start directory when start is outside $HOME", async () => {
// given
const rootDir = mkdtempSync(join(tmpdir(), "omo-plugin-config-walk-outside-"))
const userConfigDir = join(rootDir, "user-config")
const homeDir = join(rootDir, "home")
const outsideHomeRoot = join(rootDir, "outside-home")
const projectDir = join(outsideHomeRoot, "proj")
tempDirs.push(rootDir)
mkdirSync(userConfigDir, { recursive: true })
mkdirSync(homeDir, { recursive: true })
mkdirSync(join(outsideHomeRoot, ".opencode"), { recursive: true })
mkdirSync(join(projectDir, ".opencode"), { recursive: true })
writeFileSync(join(userConfigDir, "oh-my-openagent.jsonc"), "{}")
writeFileSync(
join(outsideHomeRoot, ".opencode", "oh-my-openagent.jsonc"),
JSON.stringify({ agents: { oracle: { model: "outside-home/leak" } } })
)
writeFileSync(
join(projectDir, ".opencode", "oh-my-openagent.jsonc"),
JSON.stringify({ agents: { hephaestus: { model: "project/wins" } } })
)
process.env.OPENCODE_CONFIG_DIR = userConfigDir
process.env.HOME = homeDir
// when
const { loadPluginConfig } = await importFreshPluginConfigModule()
const config = loadPluginConfig(projectDir, {})
// then - project loads, but the parent above it (outside $HOME) is not walked into
expect(config.agents?.hephaestus?.model).toBe("project/wins")
expect(config.agents?.oracle).toBeUndefined()
})
it("should merge git_master overrides across ancestors with closer winning", async () => {
// given
const rootDir = mkdtempSync(join(tmpdir(), "omo-plugin-config-walk-git-master-"))
const userConfigDir = join(rootDir, "user-config")
const homeDir = join(rootDir, "home")
const workDir = join(homeDir, "work")
const projectDir = join(workDir, "project")
tempDirs.push(rootDir)
mkdirSync(userConfigDir, { recursive: true })
mkdirSync(join(homeDir, ".opencode"), { recursive: true })
mkdirSync(join(workDir, ".opencode"), { recursive: true })
mkdirSync(join(projectDir, ".opencode"), { recursive: true })
writeFileSync(join(userConfigDir, "oh-my-openagent.jsonc"), "{}")
writeFileSync(
join(homeDir, ".opencode", "oh-my-openagent.jsonc"),
JSON.stringify({
git_master: {
commit_footer: false,
include_co_authored_by: false,
git_env_prefix: "HOME=1",
},
})
)
writeFileSync(
join(workDir, ".opencode", "oh-my-openagent.jsonc"),
JSON.stringify({
git_master: {
include_co_authored_by: true,
},
})
)
writeFileSync(
join(projectDir, ".opencode", "oh-my-openagent.jsonc"),
JSON.stringify({
git_master: {
commit_footer: true,
},
})
)
process.env.OPENCODE_CONFIG_DIR = userConfigDir
process.env.HOME = homeDir
// when
const { loadPluginConfig } = await importFreshPluginConfigModule()
const config = loadPluginConfig(projectDir, {})
// then project's commit_footer wins, work's include_co_authored_by wins,
// home's git_env_prefix is preserved since nobody else set it
expect(config.git_master).toEqual({
commit_footer: true,
include_co_authored_by: true,
git_env_prefix: "HOME=1",
})
})
it("should resolve agent_definitions relative to each ancestor's own .opencode directory", async () => {
// given
const rootDir = mkdtempSync(join(tmpdir(), "omo-plugin-config-walk-agent-defs-"))
const userConfigDir = join(rootDir, "user-config")
const homeDir = join(rootDir, "home")
const workDir = join(homeDir, "work")
const projectDir = join(workDir, "project")
const workDefRelativePath = "./work-agent.md"
const projectDefRelativePath = "./project-agent.md"
tempDirs.push(rootDir)
mkdirSync(userConfigDir, { recursive: true })
mkdirSync(join(workDir, ".opencode"), { recursive: true })
mkdirSync(join(projectDir, ".opencode"), { recursive: true })
writeFileSync(join(userConfigDir, "oh-my-openagent.jsonc"), "{}")
writeFileSync(
join(workDir, ".opencode", "oh-my-openagent.jsonc"),
JSON.stringify({ agent_definitions: [workDefRelativePath] })
)
writeFileSync(
join(projectDir, ".opencode", "oh-my-openagent.jsonc"),
JSON.stringify({ agent_definitions: [projectDefRelativePath] })
)
process.env.OPENCODE_CONFIG_DIR = userConfigDir
process.env.HOME = homeDir
// when
const { loadPluginConfig } = await importFreshPluginConfigModule()
const config = loadPluginConfig(projectDir, {})
// then each ancestor's relative path resolves against its own .opencode/
expect(config.agent_definitions).toContain(join(realpathSync(workDir), ".opencode", "work-agent.md"))
expect(config.agent_definitions).toContain(join(realpathSync(projectDir), ".opencode", "project-agent.md"))
})
it("should migrate legacy basenames found in ancestor directories", async () => {
// given
const rootDir = mkdtempSync(join(tmpdir(), "omo-plugin-config-walk-legacy-"))
const userConfigDir = join(rootDir, "user-config")
const homeDir = join(rootDir, "home")
const workDir = join(homeDir, "work")
const projectDir = join(workDir, "project")
const ancestorLegacyPath = join(workDir, ".opencode", "oh-my-opencode.jsonc")
const ancestorCanonicalPath = join(workDir, ".opencode", "oh-my-openagent.jsonc")
tempDirs.push(rootDir)
mkdirSync(userConfigDir, { recursive: true })
mkdirSync(join(homeDir, ".opencode"), { recursive: true })
mkdirSync(join(workDir, ".opencode"), { recursive: true })
mkdirSync(join(projectDir, ".opencode"), { recursive: true })
writeFileSync(join(userConfigDir, "oh-my-openagent.jsonc"), "{}")
writeFileSync(
ancestorLegacyPath,
JSON.stringify({ agents: { oracle: { model: "ancestor-legacy/model" } } })
)
process.env.OPENCODE_CONFIG_DIR = userConfigDir
process.env.HOME = homeDir
// when
const { loadPluginConfig } = await importFreshPluginConfigModule()
const config = loadPluginConfig(projectDir, {})
// then
expect(existsSync(ancestorLegacyPath)).toBe(false)
expect(existsSync(ancestorCanonicalPath)).toBe(true)
expect(config.agents?.oracle?.model).toBe("ancestor-legacy/model")
})
})
+95 -53
View File
@@ -1,19 +1,50 @@
import * as fs from "fs";
import { homedir } from "node:os";
import * as path from "path";
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "./config";
import {
log,
containsPath,
deepMerge,
getOpenCodeConfigDir,
addConfigLoadError,
parseJsonc,
detectPluginConfigFile,
findProjectOpencodePluginConfigFiles,
migrateConfigFile,
resolveAgentDefinitionPaths,
} from "./shared";
import { migrateLegacyConfigFile } from "./shared/migrate-legacy-config-file";
import { CONFIG_BASENAME, LEGACY_CONFIG_BASENAME } from "./shared/plugin-identity";
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()
}
function resolveConfigPathAfterLegacyMigration(detectedPath: string): string {
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
}
function loadExplicitGitMasterOverrides(configPath: string): Record<string, unknown> | undefined {
try {
if (!fs.existsSync(configPath)) {
@@ -214,47 +245,39 @@ export function loadPluginConfig(
}
// Auto-copy legacy config file to canonical name if needed
if (userDetected.format !== "none" && path.basename(userDetected.path).startsWith(LEGACY_CONFIG_BASENAME)) {
const migrated = migrateLegacyConfigFile(userDetected.path);
const canonicalPath = path.join(
path.dirname(userDetected.path),
`${CONFIG_BASENAME}${path.extname(userDetected.path)}`
);
// Only switch to canonical path if migration succeeded OR canonical file already exists
if (migrated || fs.existsSync(canonicalPath)) {
userConfigPath = canonicalPath;
}
// Otherwise keep loading from the legacy path that was detected
if (userDetected.format !== "none") {
userConfigPath = resolveConfigPathAfterLegacyMigration(userConfigPath)
}
// Project-level config path - prefer .jsonc over .json
const projectBasePath = path.join(directory, ".opencode");
const projectDetected = detectPluginConfigFile(projectBasePath);
let projectConfigPath =
projectDetected.format !== "none"
? projectDetected.path
: path.join(projectBasePath, `${CONFIG_BASENAME}.json`);
// 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(
directory,
stopDirectory,
)
log("Walked ancestor plugin configs", {
paths: ancestorConfigPathsNearestFirst,
count: ancestorConfigPathsNearestFirst.length,
stopDirectory,
})
if (projectDetected.legacyPath) {
log("Canonical plugin config detected alongside legacy config. Remove the legacy file to avoid confusion.", {
canonicalPath: projectDetected.path,
legacyPath: projectDetected.legacyPath,
});
}
// Auto-copy legacy project config file to canonical name if needed
if (projectDetected.format !== "none" && path.basename(projectDetected.path).startsWith(LEGACY_CONFIG_BASENAME)) {
const projectMigrated = migrateLegacyConfigFile(projectDetected.path);
const canonicalProjectPath = path.join(
path.dirname(projectDetected.path),
`${CONFIG_BASENAME}${path.extname(projectDetected.path)}`
);
// Only switch to canonical path if migration succeeded OR canonical file already exists
if (projectMigrated || fs.existsSync(canonicalProjectPath)) {
projectConfigPath = canonicalProjectPath;
}
// Otherwise keep loading from the legacy path that was detected
}
// Migrate any legacy basenames among ancestors and warn on dual-config presence
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)
},
)
// Load user config first (base). Parse empty config through Zod to apply field defaults.
const userConfig = loadConfigFromPath(userConfigPath, ctx)
@@ -271,34 +294,53 @@ export function loadPluginConfig(
let config: OhMyOpenCodeConfig =
userConfig ?? OhMyOpenCodeConfigSchema.parse({});
// Override with project config
const canonicalAncestorPathsFarthestFirst = [...canonicalAncestorPathsNearestFirst].reverse()
const defaultGitMaster = OhMyOpenCodeConfigSchema.parse({}).git_master
const projectConfig = loadConfigFromPath(projectConfigPath, ctx);
const projectGitMasterOverrides = loadExplicitGitMasterOverrides(projectConfigPath)
const ancestorGitMasterOverridesFarthestFirst: Array<Record<string, unknown>> = []
if (projectConfig?.agent_definitions) {
projectConfig.agent_definitions = resolveAgentDefinitionPaths(
projectConfig.agent_definitions,
projectBasePath,
directory
)
for (const ancestorPath of canonicalAncestorPathsFarthestFirst) {
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,
)
}
if (ancestorConfig) {
config = mergeConfigs(config, ancestorConfig)
}
if (ancestorOverrides) {
ancestorGitMasterOverridesFarthestFirst.push(ancestorOverrides)
}
}
if (projectConfig) {
config = mergeConfigs(config, projectConfig);
}
if (userGitMasterOverrides || projectGitMasterOverrides) {
if (userGitMasterOverrides || ancestorGitMasterOverridesFarthestFirst.length > 0) {
const mergedAncestorGitMaster: Record<string, unknown> = {}
for (const override of ancestorGitMasterOverridesFarthestFirst) {
Object.assign(mergedAncestorGitMaster, override)
}
config = {
...config,
git_master: {
...defaultGitMaster,
...(userGitMasterOverrides ?? {}),
...(projectGitMasterOverrides ?? {}),
...mergedAncestorGitMaster,
},
}
}
// 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.
config = {
...config,
mcp_env_allowlist: userConfig?.mcp_env_allowlist ?? [],
+91 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
import { mkdirSync, realpathSync, rmSync } from "node:fs"
import { mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
@@ -121,4 +121,94 @@ describe("project-discovery-dirs", () => {
expect(directories).toEqual([canonicalPath(join(projectDir, ".opencode", "skills"))])
})
it("#given nested .opencode plugin config files #when finding plugin config files #then returns nearest-first canonical paths", async () => {
// given
const grandparentDir = join(TEST_DIR, "grandparent")
const parentDir = join(grandparentDir, "parent")
const projectDir = join(parentDir, "project")
mkdirSync(join(grandparentDir, ".opencode"), { recursive: true })
mkdirSync(join(parentDir, ".opencode"), { recursive: true })
mkdirSync(join(projectDir, ".opencode"), { recursive: true })
writeFileSync(join(grandparentDir, ".opencode", "oh-my-openagent.jsonc"), "{}")
writeFileSync(join(parentDir, ".opencode", "oh-my-openagent.jsonc"), "{}")
writeFileSync(join(projectDir, ".opencode", "oh-my-openagent.jsonc"), "{}")
const { clearPluginConfigFileDetectionCache } = await import("./jsonc-parser")
clearPluginConfigFileDetectionCache()
const { findProjectOpencodePluginConfigFiles } = await import("./project-discovery-dirs")
// when
const paths = findProjectOpencodePluginConfigFiles(projectDir, TEST_DIR)
// then
expect(paths).toEqual([
canonicalPath(join(projectDir, ".opencode", "oh-my-openagent.jsonc")),
canonicalPath(join(parentDir, ".opencode", "oh-my-openagent.jsonc")),
canonicalPath(join(grandparentDir, ".opencode", "oh-my-openagent.jsonc")),
])
})
it("#given a stop directory #when finding plugin config files #then walking halts at the stop boundary inclusive", async () => {
// given
const stopDir = join(TEST_DIR, "stop")
const childDir = join(stopDir, "child")
mkdirSync(join(TEST_DIR, ".opencode"), { recursive: true })
mkdirSync(join(stopDir, ".opencode"), { recursive: true })
mkdirSync(join(childDir, ".opencode"), { recursive: true })
writeFileSync(join(TEST_DIR, ".opencode", "oh-my-openagent.jsonc"), "{}")
writeFileSync(join(stopDir, ".opencode", "oh-my-openagent.jsonc"), "{}")
writeFileSync(join(childDir, ".opencode", "oh-my-openagent.jsonc"), "{}")
const { clearPluginConfigFileDetectionCache } = await import("./jsonc-parser")
clearPluginConfigFileDetectionCache()
const { findProjectOpencodePluginConfigFiles } = await import("./project-discovery-dirs")
// when
const paths = findProjectOpencodePluginConfigFiles(childDir, stopDir)
// then
expect(paths).toEqual([
canonicalPath(join(childDir, ".opencode", "oh-my-openagent.jsonc")),
canonicalPath(join(stopDir, ".opencode", "oh-my-openagent.jsonc")),
])
})
it("#given a legacy basename in an ancestor #when finding plugin config files #then detection picks up the legacy path", async () => {
// given
const projectDir = join(TEST_DIR, "project")
mkdirSync(join(TEST_DIR, ".opencode"), { recursive: true })
mkdirSync(join(projectDir, ".opencode"), { recursive: true })
writeFileSync(join(TEST_DIR, ".opencode", "oh-my-opencode.jsonc"), "{}")
writeFileSync(join(projectDir, ".opencode", "oh-my-openagent.jsonc"), "{}")
const { clearPluginConfigFileDetectionCache } = await import("./jsonc-parser")
clearPluginConfigFileDetectionCache()
const { findProjectOpencodePluginConfigFiles } = await import("./project-discovery-dirs")
// when
const paths = findProjectOpencodePluginConfigFiles(projectDir, TEST_DIR)
// then
expect(paths).toEqual([
canonicalPath(join(projectDir, ".opencode", "oh-my-openagent.jsonc")),
canonicalPath(join(TEST_DIR, ".opencode", "oh-my-opencode.jsonc")),
])
})
it("#given no .opencode directories along the walk #when finding plugin config files #then returns an empty list", async () => {
// given
const projectDir = join(TEST_DIR, "project", "deep")
mkdirSync(projectDir, { recursive: true })
const { clearPluginConfigFileDetectionCache } = await import("./jsonc-parser")
clearPluginConfigFileDetectionCache()
const { findProjectOpencodePluginConfigFiles } = await import("./project-discovery-dirs")
// when
const paths = findProjectOpencodePluginConfigFiles(projectDir, TEST_DIR)
// then
expect(paths).toEqual([])
})
})
+34
View File
@@ -2,6 +2,8 @@ import { execFileSync } from "node:child_process"
import { existsSync, realpathSync } from "node:fs"
import { dirname, join, resolve } from "node:path"
import { detectPluginConfigFile } from "./jsonc-parser"
const worktreePathCache = new Map<string, string | undefined>()
function normalizePath(path: string): string {
@@ -114,3 +116,35 @@ export function findProjectOpencodeCommandDirs(startDirectory: string, stopDirec
stopDirectory ?? detectWorktreePath(startDirectory),
)
}
export function findProjectOpencodePluginConfigFiles(
startDirectory: string,
stopDirectory?: string,
): string[] {
const paths: string[] = []
const seen = new Set<string>()
let currentDirectory = normalizePath(startDirectory)
const resolvedStopDirectory = stopDirectory ? normalizePath(stopDirectory) : undefined
while (true) {
const opencodeDirectory = join(currentDirectory, ".opencode")
if (existsSync(opencodeDirectory)) {
const detected = detectPluginConfigFile(opencodeDirectory)
if (detected.format !== "none" && !seen.has(detected.path)) {
seen.add(detected.path)
paths.push(detected.path)
}
}
if (resolvedStopDirectory === currentDirectory) {
return paths
}
const parentDirectory = dirname(currentDirectory)
if (parentDirectory === currentDirectory) {
return paths
}
currentDirectory = normalizePath(parentDirectory)
}
}