diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 057754cd9ef8..3caab5a542a3 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -133,19 +133,25 @@ const layer = Layer.effect( for (const item of yield* skill.all()) { if (commands[item.name]) continue - const dir = item.location === "" ? undefined : path.dirname(item.location) commands[item.name] = { name: item.name, description: item.description, source: "skill", get template() { - if (!dir) return item.content - return [ - item.content, - "", - `Base directory for this skill: ${dir}`, - "Relative paths in this skill (e.g., scripts/, references/) are relative to this base directory.", - ].join("\n") + return bridge.promise( + skill.require(item.name).pipe( + Effect.map((current) => { + if (current.location === "") return current.content + const dir = path.dirname(current.location) + return [ + current.content, + "", + `Base directory for this skill: ${dir}`, + "Relative paths in this skill (e.g., scripts/, references/) are relative to this base directory.", + ].join("\n") + }), + ), + ) }, hints: [], } diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 5a04ec213994..20f67a9e4579 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -102,7 +102,12 @@ export interface Interface { readonly available: (agent?: Agent.Info) => Effect.Effect } -const add = Effect.fnUntraced(function* (state: State, match: string, events: EventV2Bridge.Service["Service"]) { +const add = Effect.fnUntraced(function* ( + state: State, + match: string, + events: EventV2Bridge.Service["Service"], + warnDuplicate = true, +) { const md = yield* Effect.tryPromise({ try: () => ConfigMarkdown.parse(match), catch: (err) => err, @@ -122,7 +127,7 @@ const add = Effect.fnUntraced(function* (state: State, match: string, events: Ev if (!isSkillFrontmatter(md.data)) return - if (state.skills[md.data.name]) { + if (warnDuplicate && state.skills[md.data.name]) { yield* Effect.logWarning("duplicate skill name", { name: md.data.name, existing: state.skills[md.data.name].location, @@ -294,7 +299,10 @@ const layer = Layer.effect( const require = Effect.fn("Skill.require")(function* (name: string) { const s = yield* InstanceState.get(state) const info = s.skills[name] - if (info) return info + if (info) { + if (info.location !== "") yield* add(s, info.location, events, false) + return s.skills[name] + } return yield* new NotFoundError({ name, available: Object.keys(s.skills).toSorted() }) }) diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index 1c25b253cd56..d3bd3f57c62c 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -9,6 +9,7 @@ import type { Permission } from "../../src/permission" import type { Tool } from "@/tool/tool" import { SkillTool } from "../../src/tool/skill" import { ToolRegistry } from "@/tool/registry" +import { Command } from "@/command" import { disposeAllInstances, TestInstance } from "../fixture/fixture" import { SessionID, MessageID } from "../../src/session/schema" import { testEffect } from "../lib/effect" @@ -27,7 +28,9 @@ afterEach(async () => { await disposeAllInstances() }) -const it = testEffect(LayerNode.compile(LayerNode.group([ToolRegistry.node, CrossSpawnSpawner.node, Ripgrep.node]))) +const it = testEffect( + LayerNode.compile(LayerNode.group([ToolRegistry.node, Command.node, CrossSpawnSpawner.node, Ripgrep.node])), +) describe("tool.skill", () => { it.instance("execute returns skill content block with files", () => @@ -90,6 +93,30 @@ Use this skill. expect(result.output).toContain(``) expect(result.output).toContain(`Base directory for this skill: ${skill}`) expect(result.output).toContain(`${file}`) + + const commands = yield* Command.Service + const command = yield* commands.get("tool-skill") + if (!command) throw new Error("Skill slash command not found") + expect(yield* Effect.promise(() => Promise.resolve(command.template))).toContain("Use this skill.") + + yield* Effect.promise(() => + Bun.write( + path.join(skill, "SKILL.md"), + `--- +name: tool-skill +description: Skill for tool tests. +--- + +# Tool Skill + +Updated skill content. +`, + ), + ) + + const updated = yield* tool.execute({ name: "tool-skill" }, ctx) + expect(updated.output).toContain("Updated skill content.") + expect(yield* Effect.promise(() => Promise.resolve(command.template))).toContain("Updated skill content.") }), )