Merge pull request #2845 from code-yeongyu/fix/path-discovery-parity-followup
fix: add remaining path discovery parity coverage
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"])
|
||||
})
|
||||
})
|
||||
125
src/plugin-handlers/agent-config-handler-agents-skills.test.ts
Normal file
125
src/plugin-handlers/agent-config-handler-agents-skills.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user