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:
@@ -336,7 +336,7 @@ Opinionated defaults, adjustable if you insist.
|
|||||||
See [Configuration Documentation](docs/reference/configuration.md).
|
See [Configuration Documentation](docs/reference/configuration.md).
|
||||||
|
|
||||||
**Quick Overview:**
|
**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
|
- **JSONC Support**: Comments and trailing commas supported
|
||||||
- **Agents**: Override models, temperatures, prompts, and permissions for any agent
|
- **Agents**: Override models, temperatures, prompts, and permissions for any agent
|
||||||
- **Built-in Skills**: `playwright` (browser automation), `git-master` (atomic commits)
|
- **Built-in Skills**: `playwright` (browser automation), `git-master` (atomic commits)
|
||||||
|
|||||||
@@ -43,9 +43,9 @@ Complete reference for Oh My OpenCode plugin configuration. During the rename tr
|
|||||||
|
|
||||||
### File Locations
|
### 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`):
|
2. User config (`.jsonc` preferred over `.json`):
|
||||||
|
|
||||||
| Platform | Path candidates |
|
| 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]` |
|
| 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]` |
|
| 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.
|
**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.
|
JSONC supports `// line comments`, `/* block comments */`, and trailing commas.
|
||||||
|
|
||||||
|
|||||||
+330
-3
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, describe, expect, it, mock } from "bun:test";
|
import { afterEach, describe, expect, it, mock, spyOn } from "bun:test";
|
||||||
import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"
|
||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { mergeConfigs, parseConfigPartially } from "./plugin-config";
|
import { mergeConfigs, parseConfigPartially } from "./plugin-config";
|
||||||
@@ -560,7 +560,6 @@ describe("loadPluginConfig", () => {
|
|||||||
git_env_prefix: "GIT_MASTER=1",
|
git_env_prefix: "GIT_MASTER=1",
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("team_mode.tmux_visualization", () => {
|
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 () => {
|
it("#given canonical user config enables team_mode and legacy config also exists #when loadPluginConfig runs #then tmux_visualization remains false", async () => {
|
||||||
// given
|
// given
|
||||||
@@ -639,4 +638,332 @@ describe("loadPluginConfig", () => {
|
|||||||
expect(config.team_mode).toBeUndefined()
|
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
@@ -1,19 +1,50 @@
|
|||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "./config";
|
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "./config";
|
||||||
import {
|
import {
|
||||||
log,
|
log,
|
||||||
|
containsPath,
|
||||||
deepMerge,
|
deepMerge,
|
||||||
getOpenCodeConfigDir,
|
getOpenCodeConfigDir,
|
||||||
addConfigLoadError,
|
addConfigLoadError,
|
||||||
parseJsonc,
|
parseJsonc,
|
||||||
detectPluginConfigFile,
|
detectPluginConfigFile,
|
||||||
|
findProjectOpencodePluginConfigFiles,
|
||||||
migrateConfigFile,
|
migrateConfigFile,
|
||||||
resolveAgentDefinitionPaths,
|
resolveAgentDefinitionPaths,
|
||||||
} from "./shared";
|
} from "./shared";
|
||||||
import { migrateLegacyConfigFile } from "./shared/migrate-legacy-config-file";
|
import { migrateLegacyConfigFile } from "./shared/migrate-legacy-config-file";
|
||||||
import { CONFIG_BASENAME, LEGACY_CONFIG_BASENAME } from "./shared/plugin-identity";
|
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 {
|
function loadExplicitGitMasterOverrides(configPath: string): Record<string, unknown> | undefined {
|
||||||
try {
|
try {
|
||||||
if (!fs.existsSync(configPath)) {
|
if (!fs.existsSync(configPath)) {
|
||||||
@@ -214,47 +245,39 @@ export function loadPluginConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auto-copy legacy config file to canonical name if needed
|
// Auto-copy legacy config file to canonical name if needed
|
||||||
if (userDetected.format !== "none" && path.basename(userDetected.path).startsWith(LEGACY_CONFIG_BASENAME)) {
|
if (userDetected.format !== "none") {
|
||||||
const migrated = migrateLegacyConfigFile(userDetected.path);
|
userConfigPath = resolveConfigPathAfterLegacyMigration(userConfigPath)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Project-level config path - prefer .jsonc over .json
|
// Pin the walk to $HOME only when the start directory is inside it. Outside
|
||||||
const projectBasePath = path.join(directory, ".opencode");
|
// $HOME the walker would otherwise reach FS root and surface unrelated configs
|
||||||
const projectDetected = detectPluginConfigFile(projectBasePath);
|
// in /tmp, /opt, etc.
|
||||||
let projectConfigPath =
|
const homeDirectory = resolveHomeDirectory()
|
||||||
projectDetected.format !== "none"
|
const stopDirectory = containsPath(homeDirectory, directory) ? homeDirectory : directory
|
||||||
? projectDetected.path
|
const ancestorConfigPathsNearestFirst = findProjectOpencodePluginConfigFiles(
|
||||||
: path.join(projectBasePath, `${CONFIG_BASENAME}.json`);
|
directory,
|
||||||
|
stopDirectory,
|
||||||
|
)
|
||||||
|
log("Walked ancestor plugin configs", {
|
||||||
|
paths: ancestorConfigPathsNearestFirst,
|
||||||
|
count: ancestorConfigPathsNearestFirst.length,
|
||||||
|
stopDirectory,
|
||||||
|
})
|
||||||
|
|
||||||
if (projectDetected.legacyPath) {
|
// Migrate any legacy basenames among ancestors and warn on dual-config presence
|
||||||
log("Canonical plugin config detected alongside legacy config. Remove the legacy file to avoid confusion.", {
|
const canonicalAncestorPathsNearestFirst = ancestorConfigPathsNearestFirst.map(
|
||||||
canonicalPath: projectDetected.path,
|
(ancestorPath) => {
|
||||||
legacyPath: projectDetected.legacyPath,
|
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.", {
|
||||||
// Auto-copy legacy project config file to canonical name if needed
|
canonicalPath: ancestorDetected.path,
|
||||||
if (projectDetected.format !== "none" && path.basename(projectDetected.path).startsWith(LEGACY_CONFIG_BASENAME)) {
|
legacyPath: ancestorDetected.legacyPath,
|
||||||
const projectMigrated = migrateLegacyConfigFile(projectDetected.path);
|
})
|
||||||
const canonicalProjectPath = path.join(
|
}
|
||||||
path.dirname(projectDetected.path),
|
return resolveConfigPathAfterLegacyMigration(ancestorPath)
|
||||||
`${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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load user config first (base). Parse empty config through Zod to apply field defaults.
|
// Load user config first (base). Parse empty config through Zod to apply field defaults.
|
||||||
const userConfig = loadConfigFromPath(userConfigPath, ctx)
|
const userConfig = loadConfigFromPath(userConfigPath, ctx)
|
||||||
@@ -271,34 +294,53 @@ export function loadPluginConfig(
|
|||||||
let config: OhMyOpenCodeConfig =
|
let config: OhMyOpenCodeConfig =
|
||||||
userConfig ?? OhMyOpenCodeConfigSchema.parse({});
|
userConfig ?? OhMyOpenCodeConfigSchema.parse({});
|
||||||
|
|
||||||
// Override with project config
|
const canonicalAncestorPathsFarthestFirst = [...canonicalAncestorPathsNearestFirst].reverse()
|
||||||
const defaultGitMaster = OhMyOpenCodeConfigSchema.parse({}).git_master
|
const defaultGitMaster = OhMyOpenCodeConfigSchema.parse({}).git_master
|
||||||
const projectConfig = loadConfigFromPath(projectConfigPath, ctx);
|
const ancestorGitMasterOverridesFarthestFirst: Array<Record<string, unknown>> = []
|
||||||
const projectGitMasterOverrides = loadExplicitGitMasterOverrides(projectConfigPath)
|
|
||||||
|
|
||||||
if (projectConfig?.agent_definitions) {
|
for (const ancestorPath of canonicalAncestorPathsFarthestFirst) {
|
||||||
projectConfig.agent_definitions = resolveAgentDefinitionPaths(
|
const ancestorConfig = loadConfigFromPath(ancestorPath, ctx)
|
||||||
projectConfig.agent_definitions,
|
const ancestorOverrides = loadExplicitGitMasterOverrides(ancestorPath)
|
||||||
projectBasePath,
|
|
||||||
directory
|
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) {
|
if (userGitMasterOverrides || ancestorGitMasterOverridesFarthestFirst.length > 0) {
|
||||||
config = mergeConfigs(config, projectConfig);
|
const mergedAncestorGitMaster: Record<string, unknown> = {}
|
||||||
}
|
for (const override of ancestorGitMasterOverridesFarthestFirst) {
|
||||||
|
Object.assign(mergedAncestorGitMaster, override)
|
||||||
if (userGitMasterOverrides || projectGitMasterOverrides) {
|
}
|
||||||
config = {
|
config = {
|
||||||
...config,
|
...config,
|
||||||
git_master: {
|
git_master: {
|
||||||
...defaultGitMaster,
|
...defaultGitMaster,
|
||||||
...(userGitMasterOverrides ?? {}),
|
...(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 = {
|
||||||
...config,
|
...config,
|
||||||
mcp_env_allowlist: userConfig?.mcp_env_allowlist ?? [],
|
mcp_env_allowlist: userConfig?.mcp_env_allowlist ?? [],
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
|
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 { tmpdir } from "node:os"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
|
|
||||||
@@ -121,4 +121,94 @@ describe("project-discovery-dirs", () => {
|
|||||||
expect(directories).toEqual([canonicalPath(join(projectDir, ".opencode", "skills"))])
|
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([])
|
||||||
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { execFileSync } from "node:child_process"
|
|||||||
import { existsSync, realpathSync } from "node:fs"
|
import { existsSync, realpathSync } from "node:fs"
|
||||||
import { dirname, join, resolve } from "node:path"
|
import { dirname, join, resolve } from "node:path"
|
||||||
|
|
||||||
|
import { detectPluginConfigFile } from "./jsonc-parser"
|
||||||
|
|
||||||
const worktreePathCache = new Map<string, string | undefined>()
|
const worktreePathCache = new Map<string, string | undefined>()
|
||||||
|
|
||||||
function normalizePath(path: string): string {
|
function normalizePath(path: string): string {
|
||||||
@@ -114,3 +116,35 @@ export function findProjectOpencodeCommandDirs(startDirectory: string, stopDirec
|
|||||||
stopDirectory ?? detectWorktreePath(startDirectory),
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user