feat(shared): add findProjectOpencodePluginConfigFiles walker

Walks up the directory tree from a start directory looking for
.opencode/oh-my-openagent.json[c] (or legacy basename) files. Returns
detected paths in nearest-first order, optionally stopping at a
caller-provided directory.

Reuses detectPluginConfigFile so canonical/legacy basename detection,
caching, and JSONC vs JSON precedence stay consistent with existing
config loading. Foundation for #417 hierarchical config discovery.
This commit is contained in:
Matan Kushner
2026-05-04 19:11:49 +09:00
committed by YeonGyu-Kim
parent 44216a538e
commit 01c8a2a927
2 changed files with 125 additions and 1 deletions
+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)
}
}