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 { afterEach, beforeEach, describe, expect, it } from "bun:test"
|
||||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
|
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||||
import { tmpdir } from "node:os"
|
import { tmpdir } from "node:os"
|
||||||
@@ -98,4 +99,27 @@ describe("claude-code command loader", () => {
|
|||||||
// then
|
// then
|
||||||
expect(commands["duplicate-global"]?.description).toBe("(opencode) Profile global command")
|
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,
|
findProjectOpencodeCommandDirs,
|
||||||
getClaudeConfigDir,
|
getClaudeConfigDir,
|
||||||
getOpenCodeCommandDirs,
|
getOpenCodeCommandDirs,
|
||||||
getOpenCodeConfigDir,
|
|
||||||
} from "../../shared"
|
} from "../../shared"
|
||||||
import { log } from "../../shared/logger"
|
import { log } from "../../shared/logger"
|
||||||
import type { CommandScope, CommandDefinition, CommandFrontmatter, LoadedCommand } from "./types"
|
import type { CommandScope, CommandDefinition, CommandFrontmatter, LoadedCommand } from "./types"
|
||||||
@@ -51,7 +50,7 @@ async function loadCommandsFromDir(
|
|||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
if (entry.name.startsWith(".")) continue
|
if (entry.name.startsWith(".")) continue
|
||||||
const subDirPath = join(commandsDir, entry.name)
|
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)
|
const subCommands = await loadCommandsFromDir(subDirPath, scope, visited, subPrefix)
|
||||||
commands.push(...subCommands)
|
commands.push(...subCommands)
|
||||||
continue
|
continue
|
||||||
@@ -61,7 +60,7 @@ async function loadCommandsFromDir(
|
|||||||
|
|
||||||
const commandPath = join(commandsDir, entry.name)
|
const commandPath = join(commandsDir, entry.name)
|
||||||
const baseCommandName = basename(entry.name, ".md")
|
const baseCommandName = basename(entry.name, ".md")
|
||||||
const commandName = prefix ? `${prefix}:${baseCommandName}` : baseCommandName
|
const commandName = prefix ? `${prefix}/${baseCommandName}` : baseCommandName
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const content = await fs.readFile(commandPath, "utf-8")
|
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"])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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")
|
||||||
|
})
|
||||||
|
})
|
||||||
+1
-1
@@ -62,11 +62,11 @@ export * from "./truncate-description"
|
|||||||
export * from "./opencode-storage-paths"
|
export * from "./opencode-storage-paths"
|
||||||
export * from "./opencode-message-dir"
|
export * from "./opencode-message-dir"
|
||||||
export * from "./opencode-command-dirs"
|
export * from "./opencode-command-dirs"
|
||||||
|
export * from "./project-discovery-dirs"
|
||||||
export * from "./normalize-sdk-response"
|
export * from "./normalize-sdk-response"
|
||||||
export * from "./session-directory-resolver"
|
export * from "./session-directory-resolver"
|
||||||
export * from "./prompt-tools"
|
export * from "./prompt-tools"
|
||||||
export * from "./internal-initiator-marker"
|
export * from "./internal-initiator-marker"
|
||||||
export * from "./plugin-command-discovery"
|
export * from "./plugin-command-discovery"
|
||||||
export * from "./project-discovery-dirs"
|
|
||||||
export { SessionCategoryRegistry } from "./session-category-registry"
|
export { SessionCategoryRegistry } from "./session-category-registry"
|
||||||
export * from "./plugin-identity"
|
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/skills")
|
||||||
expect(dirs).toContain("/home/user/.config/opencode/profiles/opus/skill")
|
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/skill")
|
||||||
|
expect(dirs).toContain("/home/user/.config/opencode/skills")
|
||||||
expect(dirs).toHaveLength(4)
|
expect(dirs).toHaveLength(4)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ function getParentOpencodeConfigDir(configDir: string): string | null {
|
|||||||
export function getOpenCodeCommandDirs(options: OpenCodeConfigDirOptions): string[] {
|
export function getOpenCodeCommandDirs(options: OpenCodeConfigDirOptions): string[] {
|
||||||
const configDir = getOpenCodeConfigDir(options)
|
const configDir = getOpenCodeConfigDir(options)
|
||||||
const parentConfigDir = getParentOpencodeConfigDir(configDir)
|
const parentConfigDir = getParentOpencodeConfigDir(configDir)
|
||||||
|
|
||||||
return Array.from(
|
return Array.from(
|
||||||
new Set([
|
new Set([
|
||||||
join(configDir, "commands"),
|
join(configDir, "commands"),
|
||||||
@@ -27,7 +26,6 @@ export function getOpenCodeCommandDirs(options: OpenCodeConfigDirOptions): strin
|
|||||||
export function getOpenCodeSkillDirs(options: OpenCodeConfigDirOptions): string[] {
|
export function getOpenCodeSkillDirs(options: OpenCodeConfigDirOptions): string[] {
|
||||||
const configDir = getOpenCodeConfigDir(options)
|
const configDir = getOpenCodeConfigDir(options)
|
||||||
const parentConfigDir = getParentOpencodeConfigDir(configDir)
|
const parentConfigDir = getParentOpencodeConfigDir(configDir)
|
||||||
|
|
||||||
return Array.from(
|
return Array.from(
|
||||||
new Set([
|
new Set([
|
||||||
join(configDir, "skills"),
|
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