From 2b6dd2559c72996d652b15c284c54e30acdbbcd1 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 11 Aug 2026 16:13:32 -0400 Subject: [PATCH] fix(core): prefer nearest skill source (#41788) --- packages/core/src/config.ts | 4 +- packages/core/test/config/config.test.ts | 8 ++-- packages/core/test/config/skill.test.ts | 53 ++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index f92fda5d4e1..f8541a76da4 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -204,13 +204,13 @@ export const layer = (options?: Options) => const claude = [ ...new Set([ ...((yield* fs.isDir(globalClaudeDirectory)) ? [globalClaudeDirectory] : []), - ...discovered.filter((item) => path.basename(item) === ".claude"), + ...discovered.filter((item) => path.basename(item) === ".claude").toReversed(), ]), ].map((directory) => new ClaudeDirectory({ type: "claude", path: AbsolutePath.make(directory) })) const agents = [ ...new Set([ ...((yield* fs.isDir(globalAgentsDirectory)) ? [globalAgentsDirectory] : []), - ...discovered.filter((item) => path.basename(item) === ".agents"), + ...discovered.filter((item) => path.basename(item) === ".agents").toReversed(), ]), ].map((directory) => new AgentsDirectory({ type: "agents", path: AbsolutePath.make(directory) })) diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index d0e7c7a88e0..966b5c99125 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -1464,13 +1464,13 @@ describe("Config", () => { ]) expect(entries.filter((entry) => entry.type === "agents").map((entry) => entry.path)).toEqual([ AbsolutePath.make(globalAgents), - AbsolutePath.make(path.join(directory, ".agents")), AbsolutePath.make(path.join(root, ".agents")), + AbsolutePath.make(path.join(directory, ".agents")), ]) expect(entries.filter((entry) => entry.type === "claude").map((entry) => entry.path)).toEqual([ AbsolutePath.make(globalClaude), - AbsolutePath.make(path.join(directory, ".claude")), AbsolutePath.make(path.join(root, ".claude")), + AbsolutePath.make(path.join(directory, ".claude")), ]) expect(documents.map((document) => document.info.$schema)).toEqual([ "global", @@ -1483,11 +1483,11 @@ describe("Config", () => { ]) expect(entries.map((entry) => (entry.type === "document" ? entry.info.$schema : entry.path))).toEqual([ AbsolutePath.make(globalClaude), - AbsolutePath.make(path.join(directory, ".claude")), AbsolutePath.make(path.join(root, ".claude")), + AbsolutePath.make(path.join(directory, ".claude")), AbsolutePath.make(globalAgents), - AbsolutePath.make(path.join(directory, ".agents")), AbsolutePath.make(path.join(root, ".agents")), + AbsolutePath.make(path.join(directory, ".agents")), "global", AbsolutePath.make(global), "outside", diff --git a/packages/core/test/config/skill.test.ts b/packages/core/test/config/skill.test.ts index fc2c1b847ea..95fd1a41d53 100644 --- a/packages/core/test/config/skill.test.ts +++ b/packages/core/test/config/skill.test.ts @@ -16,6 +16,7 @@ import { SkillFile } from "@opencode-ai/core/config/plugin/skill-file" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { Watcher } from "@opencode-ai/core/filesystem/watcher" import { Bus } from "@opencode-ai/core/bus" +import { Credential } from "@opencode-ai/core/credential" import { FSUtil } from "@opencode-ai/util/fs-util" import { Global } from "@opencode-ai/util/global" import { LayerNode } from "@opencode-ai/util/effect/layer-node" @@ -23,6 +24,8 @@ import { Location } from "@opencode-ai/core/location" import { AbsolutePath } from "@opencode-ai/core/schema" import { Skill } from "@opencode-ai/core/skill" import { SkillDiscovery } from "@opencode-ai/core/skill/discovery" +import { WellKnown } from "@opencode-ai/core/wellknown" +import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes" import { tmpdir } from "../fixture/tmpdir" import { location } from "../fixture/location" import { testEffect } from "../lib/effect" @@ -91,6 +94,25 @@ const start = (skills: string[], directory: string) => directory, ) +const discover = (directory: string, global: string) => + Effect.gen(function* () { + const config = yield* Config.Service + return yield* config.entries() + }).pipe( + Effect.provide( + AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node]), [ + [ + Location.node, + Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))), + ], + [Global.node, Global.layerWith({ config: global, home: path.join(global, "home") })], + [Credential.node, emptyCredentialNode], + [WellKnown.node, emptyWellknownNode], + [Watcher.node, Watcher.testLayer], + ]), + ), + ) + function emitAndWait(update: Watcher.Update) { return Effect.gen(function* () { const watcher = yield* Watcher.Test @@ -218,6 +240,37 @@ describe("ConfigSkillPlugin.Plugin", () => { ), ) + it.live("prefers a worktree skill over the parent checkout copy", () => + Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ).pipe( + Effect.flatMap((tmp) => + Effect.gen(function* () { + const checkout = path.join(tmp.path, "repo") + const worktree = path.join(checkout, ".worktrees", "feature") + const parentSkills = path.join(checkout, ".agents", "skills") + const worktreeSkills = path.join(worktree, ".agents", "skills") + yield* Effect.promise(async () => { + await fs.mkdir(path.join(checkout, ".git"), { recursive: true }) + await fs.mkdir(path.join(parentSkills, "review"), { recursive: true }) + await fs.mkdir(path.join(worktreeSkills, "review"), { recursive: true }) + await fs.writeFile(path.join(worktree, ".git"), "gitdir: ../../../.git/worktrees/feature\n") + await write(parentSkills, "review", "Parent checkout") + await write(worktreeSkills, "review", "Worktree") + }) + + const entries = yield* discover(worktree, path.join(tmp.path, "global")) + const skill = yield* startEntries(entries, worktree) + const review = (yield* skill.list()).find((item) => item.id === "review") + + expect(review?.description).toBe("Worktree") + expect(review?.location).toBe(AbsolutePath.make(path.join(worktreeSkills, "review", "SKILL.md"))) + }), + ), + ), + ) + it.live("keeps directory skills when a URL source fails", () => Effect.acquireRelease( Effect.promise(() => tmpdir()),