diff --git a/.changeset/skill-directory-ids.md b/.changeset/skill-directory-ids.md new file mode 100644 index 00000000000..5b26d9807e9 --- /dev/null +++ b/.changeset/skill-directory-ids.md @@ -0,0 +1,5 @@ +--- +"@opencode-ai/core": patch +--- + +Use parent directory names for SKILL.md IDs so individually downloaded skills do not overwrite each other. diff --git a/packages/core/src/config/plugin/skill-file.ts b/packages/core/src/config/plugin/skill-file.ts index b5245d7633d..c7e04d64ffa 100644 --- a/packages/core/src/config/plugin/skill-file.ts +++ b/packages/core/src/config/plugin/skill-file.ts @@ -39,7 +39,9 @@ export function parse(directory: string, filepath: string, content: string): Par if (Result.isFailure(decoded)) return { _tag: "Skipped", reason: "frontmatter", issue: decoded.failure } const frontmatter = decoded.success const id = - path.dirname(filepath) === directory ? path.basename(filepath, ".md") : path.basename(path.dirname(filepath)) + path.dirname(filepath) === directory && path.basename(filepath) !== "SKILL.md" + ? path.basename(filepath, ".md") + : path.basename(path.dirname(filepath)) const slash = metadataBoolean(frontmatter.metadata, "opencode/slash") ?? frontmatter.slash const autoinvoke = metadataBoolean(frontmatter.metadata, "opencode/autoinvoke") return { diff --git a/packages/core/test/config/skill.test.ts b/packages/core/test/config/skill.test.ts index 95fd1a41d53..6ab19c596e9 100644 --- a/packages/core/test/config/skill.test.ts +++ b/packages/core/test/config/skill.test.ts @@ -162,6 +162,10 @@ metadata: _tag: "Parsed", skill: { id: Skill.ID.make("foo") }, }) + expect(SkillFile.parse("/repo/skills/manual", "/repo/skills/manual/SKILL.md", "# manual")).toMatchObject({ + _tag: "Parsed", + skill: { id: Skill.ID.make("manual"), name: Skill.Name.make("manual") }, + }) expect( SkillFile.parse(directory, "/repo/skills/broken.md", "---\ndescription: foo: bar\nmetadata: [\n---\n# broken"), ).toEqual({ _tag: "Skipped", reason: "markdown" }) @@ -214,7 +218,7 @@ describe("ConfigSkillPlugin.Plugin", () => { ), ) - it.live("loads directory and URL sources with later-source precedence", () => + it.live("loads directory and individual downloaded skill roots with later-source precedence", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), @@ -225,14 +229,24 @@ describe("ConfigSkillPlugin.Plugin", () => { const second = path.join(tmp.path, "second") yield* Effect.promise(async () => { await fs.mkdir(path.join(first, "review"), { recursive: true }) + await fs.mkdir(path.join(second, "deploy"), { recursive: true }) await fs.mkdir(path.join(second, "review"), { recursive: true }) await write(first, "review", "First") + await write(second, "deploy", "Deploy") await write(second, "review", "Second") }) pulls = 0 - urls.set("https://example.test/skills/", [AbsolutePath.make(second)]) + urls.set("https://example.test/skills/", [ + AbsolutePath.make(path.join(second, "deploy")), + AbsolutePath.make(path.join(second, "review")), + ]) const skill = yield* start([first, "https://example.test/skills/"], tmp.path) + expect((yield* skill.list()).map((item) => item.id).toSorted()).toEqual([ + Skill.ID.make("deploy"), + Skill.ID.make("review"), + ]) + expect((yield* skill.list()).find((item) => item.id === "deploy")?.description).toBe("Deploy") expect((yield* skill.list()).find((item) => item.id === "review")?.description).toBe("Second") expect(pulls).toBe(1) }),