Merge pull request #2844 from code-yeongyu/fix/opencode-followup-gaps

fix: close remaining upstream path discovery gaps
This commit is contained in:
YeonGyu-Kim
2026-03-26 12:35:39 +09:00
committed by GitHub
9 changed files with 318 additions and 27 deletions
@@ -48,6 +48,20 @@ export async function loadOpencodeProjectSkills(directory?: string): Promise<Rec
return skillsToCommandDefinitionRecord(deduplicateSkillsByName(allSkills.flat()))
}
export async function loadProjectAgentsSkills(directory?: string): Promise<Record<string, CommandDefinition>> {
const agentsProjectSkillDirs = findProjectAgentsSkillDirs(directory ?? process.cwd())
const allSkills = await Promise.all(
agentsProjectSkillDirs.map((skillsDir) => loadSkillsFromDir({ skillsDir, scope: "project" })),
)
return skillsToCommandDefinitionRecord(deduplicateSkillsByName(allSkills.flat()))
}
export async function loadGlobalAgentsSkills(): Promise<Record<string, CommandDefinition>> {
const agentsGlobalDir = join(homedir(), ".agents", "skills")
const skills = await loadSkillsFromDir({ skillsDir: agentsGlobalDir, scope: "user" })
return skillsToCommandDefinitionRecord(skills)
}
export interface DiscoverSkillsOptions {
includeClaudeCodePaths?: boolean
directory?: string
@@ -8,6 +8,7 @@ import * as sisyphusJunior from "../agents/sisyphus-junior"
import type { OhMyOpenCodeConfig } from "../config"
import * as agentLoader from "../features/claude-code-agent-loader"
import * as skillLoader from "../features/opencode-skill-loader"
import type { LoadedSkill } from "../features/opencode-skill-loader"
import { getAgentDisplayName } from "../shared/agent-display-names"
import { applyAgentConfig } from "./agent-config-handler"
import type { PluginComponents } from "./plugin-components-loader"
@@ -51,6 +52,8 @@ describe("applyAgentConfig builtin override protection", () => {
let discoverProjectClaudeSkillsSpy: ReturnType<typeof spyOn>
let discoverOpencodeGlobalSkillsSpy: ReturnType<typeof spyOn>
let discoverOpencodeProjectSkillsSpy: ReturnType<typeof spyOn>
let discoverProjectAgentsSkillsSpy: ReturnType<typeof spyOn>
let discoverGlobalAgentsSkillsSpy: ReturnType<typeof spyOn>
let loadUserAgentsSpy: ReturnType<typeof spyOn>
let loadProjectAgentsSpy: ReturnType<typeof spyOn>
let migrateAgentConfigSpy: ReturnType<typeof spyOn>
@@ -121,6 +124,14 @@ describe("applyAgentConfig builtin override protection", () => {
skillLoader,
"discoverOpencodeProjectSkills",
).mockResolvedValue([])
discoverProjectAgentsSkillsSpy = spyOn(
skillLoader,
"discoverProjectAgentsSkills",
).mockResolvedValue([])
discoverGlobalAgentsSkillsSpy = spyOn(
skillLoader,
"discoverGlobalAgentsSkills",
).mockResolvedValue([])
loadUserAgentsSpy = spyOn(agentLoader, "loadUserAgents").mockReturnValue({})
loadProjectAgentsSpy = spyOn(agentLoader, "loadProjectAgents").mockReturnValue({})
@@ -139,6 +150,8 @@ describe("applyAgentConfig builtin override protection", () => {
discoverProjectClaudeSkillsSpy.mockRestore()
discoverOpencodeGlobalSkillsSpy.mockRestore()
discoverOpencodeProjectSkillsSpy.mockRestore()
discoverProjectAgentsSkillsSpy.mockRestore()
discoverGlobalAgentsSkillsSpy.mockRestore()
loadUserAgentsSpy.mockRestore()
loadProjectAgentsSpy.mockRestore()
migrateAgentConfigSpy.mockRestore()
@@ -279,4 +292,45 @@ describe("applyAgentConfig builtin override protection", () => {
// then
expect(createSisyphusJuniorAgentSpy).toHaveBeenCalledWith(undefined, "openai/gpt-5.4", false)
})
test("includes project and global .agents skills in builtin agent awareness", async () => {
// given
const projectAgentsSkill = {
name: "project-agent-skill",
definition: {
name: "project-agent-skill",
description: "Project agent skill",
template: "template",
},
scope: "project",
} satisfies LoadedSkill
const globalAgentsSkill = {
name: "global-agent-skill",
definition: {
name: "global-agent-skill",
description: "Global agent skill",
template: "template",
},
scope: "user",
} satisfies LoadedSkill
discoverProjectAgentsSkillsSpy.mockResolvedValue([projectAgentsSkill])
discoverGlobalAgentsSkillsSpy.mockResolvedValue([globalAgentsSkill])
// when
await applyAgentConfig({
config: createBaseConfig(),
pluginConfig: createPluginConfig(),
ctx: { directory: "/tmp" },
pluginComponents: createPluginComponents(),
})
// then
const discoveredSkills = createBuiltinAgentsSpy.mock.calls[0]?.[6]
expect(discoveredSkills).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "project-agent-skill" }),
expect.objectContaining({ name: "global-agent-skill" }),
]),
)
})
})
@@ -6,8 +6,10 @@ import { AGENT_NAME_MAP } from "../shared/migration";
import { getAgentDisplayName } from "../shared/agent-display-names";
import {
discoverConfigSourceSkills,
discoverGlobalAgentsSkills,
discoverOpencodeGlobalSkills,
discoverOpencodeProjectSkills,
discoverProjectAgentsSkills,
discoverProjectClaudeSkills,
discoverUserClaudeSkills,
} from "../features/opencode-skill-loader";
@@ -52,8 +54,10 @@ export async function applyAgentConfig(params: {
discoveredConfigSourceSkills,
discoveredUserSkills,
discoveredProjectSkills,
discoveredProjectAgentsSkills,
discoveredOpencodeGlobalSkills,
discoveredOpencodeProjectSkills,
discoveredGlobalAgentsSkills,
] = await Promise.all([
discoverConfigSourceSkills({
config: params.pluginConfig.skills,
@@ -63,16 +67,22 @@ export async function applyAgentConfig(params: {
includeClaudeSkillsForAwareness
? discoverProjectClaudeSkills(params.ctx.directory)
: Promise.resolve([]),
includeClaudeSkillsForAwareness
? discoverProjectAgentsSkills(params.ctx.directory)
: Promise.resolve([]),
discoverOpencodeGlobalSkills(),
discoverOpencodeProjectSkills(params.ctx.directory),
includeClaudeSkillsForAwareness ? discoverGlobalAgentsSkills() : Promise.resolve([]),
]);
const allDiscoveredSkills = [
...discoveredConfigSourceSkills,
...discoveredOpencodeProjectSkills,
...discoveredProjectSkills,
...discoveredProjectAgentsSkills,
...discoveredOpencodeGlobalSkills,
...discoveredUserSkills,
...discoveredGlobalAgentsSkills,
];
const browserProvider =
@@ -0,0 +1,98 @@
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
import * as builtinCommands from "../features/builtin-commands";
import * as commandLoader from "../features/claude-code-command-loader";
import * as skillLoader from "../features/opencode-skill-loader";
import type { OhMyOpenCodeConfig } from "../config";
import type { PluginComponents } from "./plugin-components-loader";
import { applyCommandConfig } from "./command-config-handler";
function createPluginComponents(): PluginComponents {
return {
commands: {},
skills: {},
agents: {},
mcpServers: {},
hooksConfigs: [],
plugins: [],
errors: [],
};
}
function createPluginConfig(): OhMyOpenCodeConfig {
return {};
}
describe("applyCommandConfig", () => {
let loadBuiltinCommandsSpy: ReturnType<typeof spyOn>;
let loadUserCommandsSpy: ReturnType<typeof spyOn>;
let loadProjectCommandsSpy: ReturnType<typeof spyOn>;
let loadOpencodeGlobalCommandsSpy: ReturnType<typeof spyOn>;
let loadOpencodeProjectCommandsSpy: ReturnType<typeof spyOn>;
let discoverConfigSourceSkillsSpy: ReturnType<typeof spyOn>;
let loadUserSkillsSpy: ReturnType<typeof spyOn>;
let loadProjectSkillsSpy: ReturnType<typeof spyOn>;
let loadOpencodeGlobalSkillsSpy: ReturnType<typeof spyOn>;
let loadOpencodeProjectSkillsSpy: ReturnType<typeof spyOn>;
let loadProjectAgentsSkillsSpy: ReturnType<typeof spyOn>;
let loadGlobalAgentsSkillsSpy: ReturnType<typeof spyOn>;
beforeEach(() => {
loadBuiltinCommandsSpy = spyOn(builtinCommands, "loadBuiltinCommands").mockReturnValue({});
loadUserCommandsSpy = spyOn(commandLoader, "loadUserCommands").mockResolvedValue({});
loadProjectCommandsSpy = spyOn(commandLoader, "loadProjectCommands").mockResolvedValue({});
loadOpencodeGlobalCommandsSpy = spyOn(commandLoader, "loadOpencodeGlobalCommands").mockResolvedValue({});
loadOpencodeProjectCommandsSpy = spyOn(commandLoader, "loadOpencodeProjectCommands").mockResolvedValue({});
discoverConfigSourceSkillsSpy = spyOn(skillLoader, "discoverConfigSourceSkills").mockResolvedValue([]);
loadUserSkillsSpy = spyOn(skillLoader, "loadUserSkills").mockResolvedValue({});
loadProjectSkillsSpy = spyOn(skillLoader, "loadProjectSkills").mockResolvedValue({});
loadOpencodeGlobalSkillsSpy = spyOn(skillLoader, "loadOpencodeGlobalSkills").mockResolvedValue({});
loadOpencodeProjectSkillsSpy = spyOn(skillLoader, "loadOpencodeProjectSkills").mockResolvedValue({});
loadProjectAgentsSkillsSpy = spyOn(skillLoader, "loadProjectAgentsSkills").mockResolvedValue({});
loadGlobalAgentsSkillsSpy = spyOn(skillLoader, "loadGlobalAgentsSkills").mockResolvedValue({});
});
afterEach(() => {
loadBuiltinCommandsSpy.mockRestore();
loadUserCommandsSpy.mockRestore();
loadProjectCommandsSpy.mockRestore();
loadOpencodeGlobalCommandsSpy.mockRestore();
loadOpencodeProjectCommandsSpy.mockRestore();
discoverConfigSourceSkillsSpy.mockRestore();
loadUserSkillsSpy.mockRestore();
loadProjectSkillsSpy.mockRestore();
loadOpencodeGlobalSkillsSpy.mockRestore();
loadOpencodeProjectSkillsSpy.mockRestore();
loadProjectAgentsSkillsSpy.mockRestore();
loadGlobalAgentsSkillsSpy.mockRestore();
});
test("includes .agents skills in command config", async () => {
// given
loadProjectAgentsSkillsSpy.mockResolvedValue({
"agents-project-skill": {
description: "(project - Skill) Agents project skill",
template: "template",
},
});
loadGlobalAgentsSkillsSpy.mockResolvedValue({
"agents-global-skill": {
description: "(user - Skill) Agents global skill",
template: "template",
},
});
const config: Record<string, unknown> = { command: {} };
// when
await applyCommandConfig({
config,
pluginConfig: createPluginConfig(),
ctx: { directory: "/tmp" },
pluginComponents: createPluginComponents(),
});
// then
const commandConfig = config.command as Record<string, { description?: string }>;
expect(commandConfig["agents-project-skill"]?.description).toContain("Agents project skill");
expect(commandConfig["agents-global-skill"]?.description).toContain("Agents global skill");
});
});
@@ -9,6 +9,8 @@ import {
import { loadBuiltinCommands } from "../features/builtin-commands";
import {
discoverConfigSourceSkills,
loadGlobalAgentsSkills,
loadProjectAgentsSkills,
loadUserSkills,
loadProjectSkills,
loadOpencodeGlobalSkills,
@@ -36,7 +38,9 @@ export async function applyCommandConfig(params: {
opencodeGlobalCommands,
opencodeProjectCommands,
userSkills,
globalAgentsSkills,
projectSkills,
projectAgentsSkills,
opencodeGlobalSkills,
opencodeProjectSkills,
] = await Promise.all([
@@ -49,7 +53,9 @@ export async function applyCommandConfig(params: {
loadOpencodeGlobalCommands(),
loadOpencodeProjectCommands(params.ctx.directory),
includeClaudeSkills ? loadUserSkills() : Promise.resolve({}),
includeClaudeSkills ? loadGlobalAgentsSkills() : Promise.resolve({}),
includeClaudeSkills ? loadProjectSkills(params.ctx.directory) : Promise.resolve({}),
includeClaudeSkills ? loadProjectAgentsSkills(params.ctx.directory) : Promise.resolve({}),
loadOpencodeGlobalSkills(),
loadOpencodeProjectSkills(params.ctx.directory),
]);
@@ -59,11 +65,13 @@ export async function applyCommandConfig(params: {
...skillsToCommandDefinitionRecord(configSourceSkills),
...userCommands,
...userSkills,
...globalAgentsSkills,
...opencodeGlobalCommands,
...opencodeGlobalSkills,
...systemCommands,
...projectCommands,
...projectSkills,
...projectAgentsSkills,
...opencodeProjectCommands,
...opencodeProjectSkills,
...params.pluginComponents.commands,
+26 -8
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import { mkdirSync, rmSync } from "node:fs"
import { mkdirSync, realpathSync, rmSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import {
@@ -11,6 +11,10 @@ import {
const TEST_DIR = join(tmpdir(), `project-discovery-dirs-${Date.now()}`)
function canonicalPath(path: string): string {
return realpathSync(path)
}
describe("project-discovery-dirs", () => {
beforeEach(() => {
mkdirSync(TEST_DIR, { recursive: true })
@@ -33,9 +37,9 @@ describe("project-discovery-dirs", () => {
// then
expect(directories).toEqual([
join(projectDir, ".opencode", "skills"),
join(projectDir, ".opencode", "skill"),
join(TEST_DIR, ".opencode", "skills"),
canonicalPath(join(projectDir, ".opencode", "skills")),
canonicalPath(join(projectDir, ".opencode", "skill")),
canonicalPath(join(TEST_DIR, ".opencode", "skills")),
])
})
@@ -51,8 +55,8 @@ describe("project-discovery-dirs", () => {
// then
expect(directories).toEqual([
join(projectDir, ".opencode", "commands"),
join(TEST_DIR, ".opencode", "command"),
canonicalPath(join(projectDir, ".opencode", "commands")),
canonicalPath(join(TEST_DIR, ".opencode", "command")),
])
})
@@ -68,7 +72,21 @@ describe("project-discovery-dirs", () => {
const agentsDirectories = findProjectAgentsSkillDirs(childDir)
// then
expect(claudeDirectories).toEqual([join(projectDir, ".claude", "skills")])
expect(agentsDirectories).toEqual([join(TEST_DIR, ".agents", "skills")])
expect(claudeDirectories).toEqual([canonicalPath(join(projectDir, ".claude", "skills"))])
expect(agentsDirectories).toEqual([canonicalPath(join(TEST_DIR, ".agents", "skills"))])
})
it("#given a stop directory #when finding ancestor dirs #then it does not scan beyond the stop boundary", () => {
// given
const projectDir = join(TEST_DIR, "project")
const childDir = join(projectDir, "apps", "cli")
mkdirSync(join(projectDir, ".opencode", "skills"), { recursive: true })
mkdirSync(join(TEST_DIR, ".opencode", "skills"), { recursive: true })
// when
const directories = findProjectOpencodeSkillDirs(childDir, projectDir)
// then
expect(directories).toEqual([canonicalPath(join(projectDir, ".opencode", "skills"))])
})
})
+66 -17
View File
@@ -1,13 +1,29 @@
import { existsSync } from "node:fs"
import { execFileSync } from "node:child_process"
import { existsSync, realpathSync } from "node:fs"
import { dirname, join, resolve } from "node:path"
function normalizePath(path: string): string {
const resolvedPath = resolve(path)
if (!existsSync(resolvedPath)) {
return resolvedPath
}
try {
return realpathSync(resolvedPath)
} catch {
return resolvedPath
}
}
function findAncestorDirectories(
startDirectory: string,
targetPaths: ReadonlyArray<ReadonlyArray<string>>,
stopDirectory?: string,
): string[] {
const directories: string[] = []
const seen = new Set<string>()
let currentDirectory = resolve(startDirectory)
let currentDirectory = normalizePath(startDirectory)
const resolvedStopDirectory = stopDirectory ? normalizePath(stopDirectory) : undefined
while (true) {
for (const targetPath of targetPaths) {
@@ -20,33 +36,66 @@ function findAncestorDirectories(
directories.push(candidateDirectory)
}
if (resolvedStopDirectory === currentDirectory) {
return directories
}
const parentDirectory = dirname(currentDirectory)
if (parentDirectory === currentDirectory) {
return directories
}
currentDirectory = parentDirectory
currentDirectory = normalizePath(parentDirectory)
}
}
export function findProjectClaudeSkillDirs(startDirectory: string): string[] {
return findAncestorDirectories(startDirectory, [[".claude", "skills"]])
function detectWorktreePath(directory: string): string | undefined {
try {
return execFileSync("git", ["rev-parse", "--show-toplevel"], {
cwd: directory,
encoding: "utf-8",
timeout: 5000,
stdio: ["pipe", "pipe", "pipe"],
}).trim()
} catch {
return undefined
}
}
export function findProjectAgentsSkillDirs(startDirectory: string): string[] {
return findAncestorDirectories(startDirectory, [[".agents", "skills"]])
export function findProjectClaudeSkillDirs(startDirectory: string, stopDirectory?: string): string[] {
return findAncestorDirectories(
startDirectory,
[[".claude", "skills"]],
stopDirectory ?? detectWorktreePath(startDirectory),
)
}
export function findProjectOpencodeSkillDirs(startDirectory: string): string[] {
return findAncestorDirectories(startDirectory, [
[".opencode", "skills"],
[".opencode", "skill"],
])
export function findProjectAgentsSkillDirs(startDirectory: string, stopDirectory?: string): string[] {
return findAncestorDirectories(
startDirectory,
[[".agents", "skills"]],
stopDirectory ?? detectWorktreePath(startDirectory),
)
}
export function findProjectOpencodeCommandDirs(startDirectory: string): string[] {
return findAncestorDirectories(startDirectory, [
[".opencode", "commands"],
[".opencode", "command"],
])
export function findProjectOpencodeSkillDirs(startDirectory: string, stopDirectory?: string): string[] {
return findAncestorDirectories(
startDirectory,
[
[".opencode", "skills"],
[".opencode", "skill"],
],
stopDirectory ?? detectWorktreePath(startDirectory),
)
}
export function findProjectOpencodeCommandDirs(startDirectory: string, stopDirectory?: string): string[] {
return findAncestorDirectories(
startDirectory,
[
[".opencode", "commands"],
[".opencode", "command"],
],
stopDirectory ?? detectWorktreePath(startDirectory),
)
}
@@ -235,4 +235,24 @@ Use plural command.
expect(duplicates).toHaveLength(1)
expect(duplicates[0]?.content).toContain("Use plural command.")
})
it("discovers nested opencode project commands", () => {
const commandsDir = join(projectDir, ".opencode", "commands", "refactor")
mkdirSync(commandsDir, { recursive: true })
writeFileSync(
join(commandsDir, "code.md"),
`---
description: Nested command
---
Use nested command.
`,
)
const commands = discoverCommandsSync(projectDir)
const nestedCommand = commands.find((command) => command.name === "refactor/code")
expect(nestedCommand?.content).toContain("Use nested command.")
expect(nestedCommand?.scope).toBe("opencode-project")
})
})
+22 -2
View File
@@ -18,17 +18,37 @@ export interface CommandDiscoveryOptions {
enabledPluginsOverride?: Record<string, boolean>
}
function discoverCommandsFromDir(commandsDir: string, scope: CommandScope): CommandInfo[] {
const NESTED_COMMAND_SEPARATOR = "/"
function discoverCommandsFromDir(
commandsDir: string,
scope: CommandScope,
prefix = "",
): CommandInfo[] {
if (!existsSync(commandsDir)) return []
const entries = readdirSync(commandsDir, { withFileTypes: true })
const commands: CommandInfo[] = []
for (const entry of entries) {
if (entry.isDirectory()) {
if (entry.name.startsWith(".")) continue
const nestedPrefix = prefix
? `${prefix}${NESTED_COMMAND_SEPARATOR}${entry.name}`
: entry.name
commands.push(
...discoverCommandsFromDir(join(commandsDir, entry.name), scope, nestedPrefix),
)
continue
}
if (!isMarkdownFile(entry)) continue
const commandPath = join(commandsDir, entry.name)
const commandName = basename(entry.name, ".md")
const baseCommandName = basename(entry.name, ".md")
const commandName = prefix
? `${prefix}${NESTED_COMMAND_SEPARATOR}${baseCommandName}`
: baseCommandName
try {
const content = readFileSync(commandPath, "utf-8")