Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 17 additions & 16 deletions packages/opencode/src/skill/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,8 @@ export interface Interface {
readonly invalidate: () => Effect.Effect<void>
}

const add = Effect.fnUntraced(function* (state: State, match: string, events: EventV2Bridge.Service["Service"]) {
const md = yield* Effect.tryPromise({
const parse = Effect.fnUntraced(function* (match: string, events: EventV2Bridge.Service["Service"]) {
return yield* Effect.tryPromise({
try: () => ConfigMarkdown.parse(match),
catch: (err) => err,
}).pipe(
Expand All @@ -118,27 +118,23 @@ const add = Effect.fnUntraced(function* (state: State, match: string, events: Ev
}),
),
)
})

if (!md) return

if (!isSkillFrontmatter(md.data)) return

if (state.skills[md.data.name]) {
yield* Effect.logWarning("duplicate skill name", {
name: md.data.name,
existing: state.skills[md.data.name].location,
duplicate: match,
})
}
function add(state: State, match: string, md: Awaited<ReturnType<typeof ConfigMarkdown.parse>>) {
if (!isSkillFrontmatter(md.data)) return Effect.void

const existing = state.skills[md.data.name]
state.dirs.add(path.dirname(match))
state.skills[md.data.name] = {
name: md.data.name,
description: md.data.description,
location: match,
content: md.content,
}
})
return existing
? Effect.logWarning("duplicate skill name", { name: md.data.name, existing: existing.location, duplicate: match })
: Effect.void
}

const scan = Effect.fnUntraced(function* (
state: ScanState,
Expand Down Expand Up @@ -166,6 +162,8 @@ const scan = Effect.fnUntraced(function* (
)

for (const match of matches) {
// A file found again moves to its latest source, so the source listed last wins a name clash.
state.matches.delete(match)
state.matches.add(match)
state.dirs.add(path.dirname(match))
}
Expand Down Expand Up @@ -238,10 +236,13 @@ const loadSkills = Effect.fnUntraced(function* (
discovered: DiscoveryState,
events: EventV2Bridge.Service["Service"],
) {
yield* Effect.forEach(discovered.matches, (match) => add(state, match, events), {
// Parse in parallel but register in discovery order, so the same files always resolve a name clash the same way.
const parsed = yield* Effect.forEach(discovered.matches, (match) => parse(match, events), {
concurrency: "unbounded",
discard: true,
})
for (const [index, md] of parsed.entries()) {
if (md) yield* add(state, discovered.matches[index], md)
}

yield* Effect.logInfo("init", { count: Object.keys(state.skills).length })
})
Expand Down
21 changes: 21 additions & 0 deletions packages/opencode/test/skill/skill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,27 @@ Instructions here.
),
)

it.live("the skills path listed last wins a name clash, every time", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const write = (folder: string, description: string) =>
Bun.write(
path.join(dir, folder, "brand-voice", "SKILL.md"),
`---\nname: brand-voice\ndescription: ${description}\n---\n\n# Brand voice\n`,
)
yield* Effect.promise(() =>
Promise.all([write(path.join(".opencode", "skills"), "personal"), write("team", "team")]),
)

const skill = yield* Skill.Service
const item = (yield* skill.all()).find((x) => x.name === "brand-voice")
expect(item?.description).toBe("personal")
}),
{ git: true, config: { skills: { paths: ["team", ".opencode/skills"] } } },
),
)

it.live("returns skill directories from Skill.dirs", () =>
provideTmpdirInstance(
(dir) =>
Expand Down
Loading