From da3e80464d2a93faeb679e4d914bb04bd53ec164 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 26 Mar 2026 11:22:00 +0900 Subject: [PATCH] fix(shared): add ancestor project discovery helpers Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/shared/index.ts | 1 + src/shared/project-discovery-dirs.test.ts | 74 +++++++++++++++++++++++ src/shared/project-discovery-dirs.ts | 52 ++++++++++++++++ 3 files changed, 127 insertions(+) create mode 100644 src/shared/project-discovery-dirs.test.ts create mode 100644 src/shared/project-discovery-dirs.ts diff --git a/src/shared/index.ts b/src/shared/index.ts index 726b55fa5..5d2615d70 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -67,5 +67,6 @@ export * from "./session-directory-resolver" export * from "./prompt-tools" export * from "./internal-initiator-marker" export * from "./plugin-command-discovery" +export * from "./project-discovery-dirs" export { SessionCategoryRegistry } from "./session-category-registry" export * from "./plugin-identity" diff --git a/src/shared/project-discovery-dirs.test.ts b/src/shared/project-discovery-dirs.test.ts new file mode 100644 index 000000000..13dcc8a71 --- /dev/null +++ b/src/shared/project-discovery-dirs.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test" +import { mkdirSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { + findProjectAgentsSkillDirs, + findProjectClaudeSkillDirs, + findProjectOpencodeCommandDirs, + findProjectOpencodeSkillDirs, +} from "./project-discovery-dirs" + +const TEST_DIR = join(tmpdir(), `project-discovery-dirs-${Date.now()}`) + +describe("project-discovery-dirs", () => { + beforeEach(() => { + mkdirSync(TEST_DIR, { recursive: true }) + }) + + afterEach(() => { + rmSync(TEST_DIR, { recursive: true, force: true }) + }) + + it("#given nested .opencode skill directories #when finding project opencode skill dirs #then returns nearest-first with aliases", () => { + // given + const projectDir = join(TEST_DIR, "project") + const childDir = join(projectDir, "apps", "cli") + mkdirSync(join(projectDir, ".opencode", "skill"), { recursive: true }) + mkdirSync(join(projectDir, ".opencode", "skills"), { recursive: true }) + mkdirSync(join(TEST_DIR, ".opencode", "skills"), { recursive: true }) + + // when + const directories = findProjectOpencodeSkillDirs(childDir) + + // then + expect(directories).toEqual([ + join(projectDir, ".opencode", "skills"), + join(projectDir, ".opencode", "skill"), + join(TEST_DIR, ".opencode", "skills"), + ]) + }) + + it("#given nested .opencode command directories #when finding project opencode command dirs #then returns nearest-first with aliases", () => { + // given + const projectDir = join(TEST_DIR, "project") + const childDir = join(projectDir, "packages", "tool") + mkdirSync(join(projectDir, ".opencode", "commands"), { recursive: true }) + mkdirSync(join(TEST_DIR, ".opencode", "command"), { recursive: true }) + + // when + const directories = findProjectOpencodeCommandDirs(childDir) + + // then + expect(directories).toEqual([ + join(projectDir, ".opencode", "commands"), + join(TEST_DIR, ".opencode", "command"), + ]) + }) + + it("#given ancestor claude and agents skill directories #when finding project compatibility dirs #then discovers both scopes", () => { + // given + const projectDir = join(TEST_DIR, "project") + const childDir = join(projectDir, "src", "nested") + mkdirSync(join(projectDir, ".claude", "skills"), { recursive: true }) + mkdirSync(join(TEST_DIR, ".agents", "skills"), { recursive: true }) + + // when + const claudeDirectories = findProjectClaudeSkillDirs(childDir) + const agentsDirectories = findProjectAgentsSkillDirs(childDir) + + // then + expect(claudeDirectories).toEqual([join(projectDir, ".claude", "skills")]) + expect(agentsDirectories).toEqual([join(TEST_DIR, ".agents", "skills")]) + }) +}) diff --git a/src/shared/project-discovery-dirs.ts b/src/shared/project-discovery-dirs.ts new file mode 100644 index 000000000..007c3c16b --- /dev/null +++ b/src/shared/project-discovery-dirs.ts @@ -0,0 +1,52 @@ +import { existsSync } from "node:fs" +import { dirname, join, resolve } from "node:path" + +function findAncestorDirectories( + startDirectory: string, + targetPaths: ReadonlyArray>, +): string[] { + const directories: string[] = [] + const seen = new Set() + let currentDirectory = resolve(startDirectory) + + while (true) { + for (const targetPath of targetPaths) { + const candidateDirectory = join(currentDirectory, ...targetPath) + if (!existsSync(candidateDirectory) || seen.has(candidateDirectory)) { + continue + } + + seen.add(candidateDirectory) + directories.push(candidateDirectory) + } + + const parentDirectory = dirname(currentDirectory) + if (parentDirectory === currentDirectory) { + return directories + } + + currentDirectory = parentDirectory + } +} + +export function findProjectClaudeSkillDirs(startDirectory: string): string[] { + return findAncestorDirectories(startDirectory, [[".claude", "skills"]]) +} + +export function findProjectAgentsSkillDirs(startDirectory: string): string[] { + return findAncestorDirectories(startDirectory, [[".agents", "skills"]]) +} + +export function findProjectOpencodeSkillDirs(startDirectory: string): string[] { + return findAncestorDirectories(startDirectory, [ + [".opencode", "skills"], + [".opencode", "skill"], + ]) +} + +export function findProjectOpencodeCommandDirs(startDirectory: string): string[] { + return findAncestorDirectories(startDirectory, [ + [".opencode", "commands"], + [".opencode", "command"], + ]) +}