Merge pull request #2845 from code-yeongyu/fix/path-discovery-parity-followup

fix: add remaining path discovery parity coverage
This commit is contained in:
YeonGyu-Kim
2026-03-26 13:13:15 +09:00
committed by GitHub
8 changed files with 299 additions and 7 deletions

View File

@@ -1,3 +1,4 @@
import { execFileSync } from "node:child_process"
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
@@ -98,4 +99,27 @@ describe("claude-code command loader", () => {
// then
expect(commands["duplicate-global"]?.description).toBe("(opencode) Profile global command")
})
it("#given nested project opencode commands in a worktree #when loadOpencodeProjectCommands is called #then it preserves slash names and stops at the worktree root", async () => {
// given
const repositoryDir = join(TEST_DIR, "repo")
const nestedDirectory = join(repositoryDir, "packages", "app", "src")
mkdirSync(nestedDirectory, { recursive: true })
execFileSync("git", ["init"], {
cwd: repositoryDir,
stdio: ["ignore", "ignore", "ignore"],
})
writeCommand(join(repositoryDir, ".opencode", "commands", "deploy"), "staging", "Deploy staging")
writeCommand(join(repositoryDir, ".opencode", "command"), "release", "Release command")
writeCommand(join(TEST_DIR, ".opencode", "commands"), "outside", "Outside command")
// when
const commands = await loadOpencodeProjectCommands(nestedDirectory)
// then
expect(commands["deploy/staging"]?.description).toBe("(opencode-project) Deploy staging")
expect(commands.release?.description).toBe("(opencode-project) Release command")
expect(commands.outside).toBeUndefined()
expect(commands["deploy:staging"]).toBeUndefined()
})
})

View File

@@ -7,7 +7,6 @@ import {
findProjectOpencodeCommandDirs,
getClaudeConfigDir,
getOpenCodeCommandDirs,
getOpenCodeConfigDir,
} from "../../shared"
import { log } from "../../shared/logger"
import type { CommandScope, CommandDefinition, CommandFrontmatter, LoadedCommand } from "./types"
@@ -51,7 +50,7 @@ async function loadCommandsFromDir(
if (entry.isDirectory()) {
if (entry.name.startsWith(".")) continue
const subDirPath = join(commandsDir, entry.name)
const subPrefix = prefix ? `${prefix}:${entry.name}` : entry.name
const subPrefix = prefix ? `${prefix}/${entry.name}` : entry.name
const subCommands = await loadCommandsFromDir(subDirPath, scope, visited, subPrefix)
commands.push(...subCommands)
continue
@@ -61,7 +60,7 @@ async function loadCommandsFromDir(
const commandPath = join(commandsDir, entry.name)
const baseCommandName = basename(entry.name, ".md")
const commandName = prefix ? `${prefix}:${baseCommandName}` : baseCommandName
const commandName = prefix ? `${prefix}/${baseCommandName}` : baseCommandName
try {
const content = await fs.readFile(commandPath, "utf-8")

View File

@@ -0,0 +1,86 @@
import { execFileSync } from "node:child_process"
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import {
discoverOpencodeProjectSkills,
discoverProjectAgentsSkills,
discoverProjectClaudeSkills,
} from "./loader"
function writeSkill(directory: string, name: string, description: string): void {
mkdirSync(directory, { recursive: true })
writeFileSync(
join(directory, "SKILL.md"),
`---\nname: ${name}\ndescription: ${description}\n---\nBody\n`,
)
}
describe("project skill discovery", () => {
let tempDir = ""
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "omo-project-skill-discovery-"))
})
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true })
})
it("discovers ancestor project skill directories up to the worktree root", async () => {
// given
const repositoryDir = join(tempDir, "repo")
const nestedDirectory = join(repositoryDir, "packages", "app", "src")
mkdirSync(nestedDirectory, { recursive: true })
execFileSync("git", ["init"], {
cwd: repositoryDir,
stdio: ["ignore", "ignore", "ignore"],
})
writeSkill(
join(repositoryDir, ".claude", "skills", "repo-claude"),
"repo-claude",
"Discovered from the repository root",
)
writeSkill(
join(repositoryDir, ".agents", "skills", "repo-agents"),
"repo-agents",
"Discovered from the repository root",
)
writeSkill(
join(repositoryDir, ".opencode", "skill", "repo-opencode"),
"repo-opencode",
"Discovered from the repository root",
)
writeSkill(
join(tempDir, ".claude", "skills", "outside-claude"),
"outside-claude",
"Should stay outside the worktree",
)
writeSkill(
join(tempDir, ".agents", "skills", "outside-agents"),
"outside-agents",
"Should stay outside the worktree",
)
writeSkill(
join(tempDir, ".opencode", "skills", "outside-opencode"),
"outside-opencode",
"Should stay outside the worktree",
)
// when
const [claudeSkills, agentSkills, opencodeSkills] = await Promise.all([
discoverProjectClaudeSkills(nestedDirectory),
discoverProjectAgentsSkills(nestedDirectory),
discoverOpencodeProjectSkills(nestedDirectory),
])
// then
expect(claudeSkills.map(skill => skill.name)).toEqual(["repo-claude"])
expect(agentSkills.map(skill => skill.name)).toEqual(["repo-agents"])
expect(opencodeSkills.map(skill => skill.name)).toEqual(["repo-opencode"])
})
})

View File

@@ -0,0 +1,125 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"
import * as agents from "../agents"
import * as shared from "../shared"
import * as sisyphusJunior from "../agents/sisyphus-junior"
import type { OhMyOpenCodeConfig } from "../config"
import * as skillLoader from "../features/opencode-skill-loader"
import { applyAgentConfig } from "./agent-config-handler"
import type { PluginComponents } from "./plugin-components-loader"
function createPluginComponents(): PluginComponents {
return {
commands: {},
skills: {},
agents: {},
mcpServers: {},
hooksConfigs: [],
plugins: [],
errors: [],
}
}
function createPluginConfig(): OhMyOpenCodeConfig {
return {
sisyphus_agent: {
planner_enabled: false,
},
}
}
describe("applyAgentConfig .agents skills", () => {
let createBuiltinAgentsSpy: ReturnType<typeof spyOn>
let createSisyphusJuniorAgentSpy: ReturnType<typeof spyOn>
let discoverConfigSourceSkillsSpy: ReturnType<typeof spyOn>
let discoverUserClaudeSkillsSpy: ReturnType<typeof spyOn>
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 logSpy: ReturnType<typeof spyOn>
beforeEach(() => {
createBuiltinAgentsSpy = spyOn(agents, "createBuiltinAgents").mockResolvedValue({
sisyphus: { name: "sisyphus", prompt: "builtin", mode: "primary" } satisfies AgentConfig,
})
createSisyphusJuniorAgentSpy = spyOn(
sisyphusJunior,
"createSisyphusJuniorAgentWithOverrides",
).mockReturnValue({
name: "sisyphus-junior",
prompt: "junior",
mode: "all",
} satisfies AgentConfig)
discoverConfigSourceSkillsSpy = spyOn(skillLoader, "discoverConfigSourceSkills").mockResolvedValue([])
discoverUserClaudeSkillsSpy = spyOn(skillLoader, "discoverUserClaudeSkills").mockResolvedValue([])
discoverProjectClaudeSkillsSpy = spyOn(skillLoader, "discoverProjectClaudeSkills").mockResolvedValue([])
discoverOpencodeGlobalSkillsSpy = spyOn(skillLoader, "discoverOpencodeGlobalSkills").mockResolvedValue([])
discoverOpencodeProjectSkillsSpy = spyOn(skillLoader, "discoverOpencodeProjectSkills").mockResolvedValue([])
discoverProjectAgentsSkillsSpy = spyOn(skillLoader, "discoverProjectAgentsSkills").mockResolvedValue([])
discoverGlobalAgentsSkillsSpy = spyOn(skillLoader, "discoverGlobalAgentsSkills").mockResolvedValue([])
logSpy = spyOn(shared, "log").mockImplementation(() => {})
})
afterEach(() => {
createBuiltinAgentsSpy.mockRestore()
createSisyphusJuniorAgentSpy.mockRestore()
discoverConfigSourceSkillsSpy.mockRestore()
discoverUserClaudeSkillsSpy.mockRestore()
discoverProjectClaudeSkillsSpy.mockRestore()
discoverOpencodeGlobalSkillsSpy.mockRestore()
discoverOpencodeProjectSkillsSpy.mockRestore()
discoverProjectAgentsSkillsSpy.mockRestore()
discoverGlobalAgentsSkillsSpy.mockRestore()
logSpy.mockRestore()
})
test("calls .agents skill discovery during agent configuration", async () => {
// given
const directory = "/tmp/project"
// when
await applyAgentConfig({
config: { model: "anthropic/claude-opus-4-6", agent: {} },
pluginConfig: createPluginConfig(),
ctx: { directory },
pluginComponents: createPluginComponents(),
})
// then
expect(discoverProjectAgentsSkillsSpy).toHaveBeenCalledWith(directory)
expect(discoverGlobalAgentsSkillsSpy).toHaveBeenCalled()
})
test("passes discovered .agents skills to builtin agent creation", async () => {
// given
discoverProjectAgentsSkillsSpy.mockResolvedValue([
{
name: "project-agent-skill",
definition: { name: "project-agent-skill", template: "project-template" },
scope: "project",
},
])
discoverGlobalAgentsSkillsSpy.mockResolvedValue([
{
name: "global-agent-skill",
definition: { name: "global-agent-skill", template: "global-template" },
scope: "user",
},
])
// when
await applyAgentConfig({
config: { model: "anthropic/claude-opus-4-6", agent: {} },
pluginConfig: createPluginConfig(),
ctx: { directory: "/tmp/project" },
pluginComponents: createPluginComponents(),
})
// then
const discoveredSkills = createBuiltinAgentsSpy.mock.calls[0]?.[6] as Array<{ name: string }>
expect(discoveredSkills.map(skill => skill.name)).toContain("project-agent-skill")
expect(discoveredSkills.map(skill => skill.name)).toContain("global-agent-skill")
})
})

View File

@@ -62,11 +62,11 @@ export * from "./truncate-description"
export * from "./opencode-storage-paths"
export * from "./opencode-message-dir"
export * from "./opencode-command-dirs"
export * from "./project-discovery-dirs"
export * from "./normalize-sdk-response"
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"

View File

@@ -27,8 +27,8 @@ describe("opencode-command-dirs", () => {
expect(dirs).toContain("/home/user/.config/opencode/profiles/opus/skills")
expect(dirs).toContain("/home/user/.config/opencode/profiles/opus/skill")
expect(dirs).toContain("/home/user/.config/opencode/skills")
expect(dirs).toContain("/home/user/.config/opencode/skill")
expect(dirs).toContain("/home/user/.config/opencode/skills")
expect(dirs).toHaveLength(4)
})
})

View File

@@ -14,7 +14,6 @@ function getParentOpencodeConfigDir(configDir: string): string | null {
export function getOpenCodeCommandDirs(options: OpenCodeConfigDirOptions): string[] {
const configDir = getOpenCodeConfigDir(options)
const parentConfigDir = getParentOpencodeConfigDir(configDir)
return Array.from(
new Set([
join(configDir, "commands"),
@@ -27,7 +26,6 @@ export function getOpenCodeCommandDirs(options: OpenCodeConfigDirOptions): strin
export function getOpenCodeSkillDirs(options: OpenCodeConfigDirOptions): string[] {
const configDir = getOpenCodeConfigDir(options)
const parentConfigDir = getParentOpencodeConfigDir(configDir)
return Array.from(
new Set([
join(configDir, "skills"),

View File

@@ -0,0 +1,60 @@
import { execFileSync } from "node:child_process"
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { discoverCommandsSync } from "./command-discovery"
function writeCommand(path: string, description: string, body: string): void {
mkdirSync(join(path, ".."), { recursive: true })
writeFileSync(path, `---\ndescription: ${description}\n---\n${body}\n`)
}
describe("opencode project command discovery", () => {
let tempDir = ""
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "omo-opencode-project-command-discovery-"))
})
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true })
})
it("discovers ancestor opencode commands with slash-separated nested names and worktree boundaries", () => {
// given
const repositoryDir = join(tempDir, "repo")
const nestedDirectory = join(repositoryDir, "packages", "app", "src")
mkdirSync(nestedDirectory, { recursive: true })
execFileSync("git", ["init"], {
cwd: repositoryDir,
stdio: ["ignore", "ignore", "ignore"],
})
writeCommand(
join(repositoryDir, ".opencode", "commands", "deploy", "staging.md"),
"Deploy to staging",
"Run the staged deploy.",
)
writeCommand(
join(repositoryDir, ".opencode", "command", "release.md"),
"Release command",
"Run the release.",
)
writeCommand(
join(tempDir, ".opencode", "commands", "outside.md"),
"Outside command",
"Should not be discovered.",
)
// when
const names = discoverCommandsSync(nestedDirectory).map(command => command.name)
// then
expect(names).toContain("deploy/staging")
expect(names).toContain("release")
expect(names).not.toContain("deploy:staging")
expect(names).not.toContain("outside")
})
})