fix(opencode-skill-loader): align getSkillByName with short-name matching (fixes #4183)

PR #4146 fixed the async resolvers (resolveSkillContentAsync, resolveMultipleSkillsAsync) to support unambiguous short-name skill lookups by routing through matchSkillByName. But the public getSkillByName API (exported from src/features/opencode-skill-loader/) was left on the old skills.find(s => s.name === name) lookup, so any external caller asking for 'systematic-debugging' on a 'superpowers/systematic-debugging' skill silently gets undefined.

Wire getSkillByName through the same matchSkillByName helper so the public API matches the async resolution semantics:
- Exact full-name lookup (case-insensitive) is preferred.
- Unambiguous short-name lookup falls back to the unique namespaced match.
- Ambiguous short-name (>=2 namespaces share the same short tail) returns undefined, mirroring matchSkillByName.

Regression tests added to src/features/opencode-skill-loader/loader.test.ts under a new 'getSkillByName' describe block:
- exact full-name returns the skill
- namespaced skill resolves by its unique short name
- ambiguous short name returns undefined

Verification: 3/3 new cases pass, 129/129 full src/features/opencode-skill-loader tests pass (was 126/126 pre-fix), bun run typecheck clean.
This commit is contained in:
MoerAI
2026-05-21 18:45:00 +09:00
parent f1bf61efb9
commit bac9a6057a
2 changed files with 88 additions and 1 deletions
@@ -703,4 +703,90 @@ Skill body.
}
})
})
describe("getSkillByName", () => {
it("#given a discoverable skill #when getSkillByName is called with the exact full name #then it returns the skill", async () => {
// given - a skill with a plain (non-namespaced) name
const skillContent = `---
name: my-exact-skill
description: A skill resolvable by exact name
---
Body.
`
createTestSkill("my-exact-skill", skillContent)
// when
const { getSkillByName } = await import("./loader")
const originalCwd = process.cwd()
process.chdir(TEST_DIR)
try {
const skill = await getSkillByName("my-exact-skill", { includeClaudeCodePaths: false })
// then
expect(skill).toBeDefined()
expect(skill?.name).toBe("my-exact-skill")
} finally {
process.chdir(originalCwd)
}
})
it("#given a namespaced skill #when getSkillByName is called with its unique short name #then it returns the skill", async () => {
// given - a namespaced skill that is the unique short-name match
const skillContent = `---
name: superpowers/systematic-debugging
description: Namespaced skill the agent should be able to load by short name
---
Body.
`
createTestSkill("systematic-debugging", skillContent)
// when
const { getSkillByName } = await import("./loader")
const originalCwd = process.cwd()
process.chdir(TEST_DIR)
try {
const skill = await getSkillByName("systematic-debugging", { includeClaudeCodePaths: false })
// then - the short-name lookup must succeed, mirroring matchSkillByName semantics
expect(skill).toBeDefined()
expect(skill?.name).toBe("superpowers/systematic-debugging")
} finally {
process.chdir(originalCwd)
}
})
it("#given two namespaced skills sharing a short name #when getSkillByName is called with that short name #then it returns undefined (ambiguous)", async () => {
// given - two skills under different namespaces with the same short name
const skillA = `---
name: alpha/duplicated
description: Skill A
---
Body A.
`
const skillB = `---
name: beta/duplicated
description: Skill B
---
Body B.
`
createTestSkill("alpha-duplicated", skillA)
createTestSkill("beta-duplicated", skillB)
// when
const { getSkillByName } = await import("./loader")
const originalCwd = process.cwd()
process.chdir(TEST_DIR)
try {
const skill = await getSkillByName("duplicated", { includeClaudeCodePaths: false })
// then - ambiguous short-name match must NOT resolve, matching matchSkillByName behavior
expect(skill).toBeUndefined()
} finally {
process.chdir(originalCwd)
}
})
})
})
+2 -1
View File
@@ -8,6 +8,7 @@ import {
findProjectClaudeSkillDirs,
findProjectOpencodeSkillDirs,
} from "../../shared/project-discovery-dirs"
import { matchSkillByName } from "../../tools/skill/skill-matcher"
import type { CommandDefinition } from "../claude-code-command-loader/types"
import type { LoadedSkill } from "./types"
import { skillsToCommandDefinitionRecord } from "./skill-definition-record"
@@ -122,7 +123,7 @@ export async function discoverSkills(options: DiscoverSkillsOptions = {}): Promi
export async function getSkillByName(name: string, options: DiscoverSkillsOptions = {}): Promise<LoadedSkill | undefined> {
const skills = await discoverSkills(options)
return skills.find(s => s.name === name)
return matchSkillByName(skills, name)
}
export async function discoverUserClaudeSkills(): Promise<LoadedSkill[]> {