From 01c8a2a9271cdb250cf65004054646915b19d524 Mon Sep 17 00:00:00 2001 From: Matan Kushner Date: Mon, 4 May 2026 19:11:49 +0900 Subject: [PATCH] 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. --- src/shared/project-discovery-dirs.test.ts | 92 ++++++++++++++++++++++- src/shared/project-discovery-dirs.ts | 34 +++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/src/shared/project-discovery-dirs.test.ts b/src/shared/project-discovery-dirs.test.ts index d2904bc72..b2aab0c1b 100644 --- a/src/shared/project-discovery-dirs.test.ts +++ b/src/shared/project-discovery-dirs.test.ts @@ -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([]) + }) + }) diff --git a/src/shared/project-discovery-dirs.ts b/src/shared/project-discovery-dirs.ts index 5e243df5a..ee53f5486 100644 --- a/src/shared/project-discovery-dirs.ts +++ b/src/shared/project-discovery-dirs.ts @@ -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() 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() + 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) + } +}