From bac9a6057a9c2bc24e872382214c2d302cf0b7f4 Mon Sep 17 00:00:00 2001 From: MoerAI Date: Thu, 21 May 2026 18:45:00 +0900 Subject: [PATCH] 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. --- .../opencode-skill-loader/loader.test.ts | 86 +++++++++++++++++++ src/features/opencode-skill-loader/loader.ts | 3 +- 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/src/features/opencode-skill-loader/loader.test.ts b/src/features/opencode-skill-loader/loader.test.ts index c5042e0b1..fbf6ce9e0 100644 --- a/src/features/opencode-skill-loader/loader.test.ts +++ b/src/features/opencode-skill-loader/loader.test.ts @@ -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) + } + }) + }) }) diff --git a/src/features/opencode-skill-loader/loader.ts b/src/features/opencode-skill-loader/loader.ts index 3768eaa34..9e53434ff 100644 --- a/src/features/opencode-skill-loader/loader.ts +++ b/src/features/opencode-skill-loader/loader.ts @@ -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 { const skills = await discoverSkills(options) - return skills.find(s => s.name === name) + return matchSkillByName(skills, name) } export async function discoverUserClaudeSkills(): Promise {