Skip to content
Open
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
22 changes: 14 additions & 8 deletions packages/opencode/src/command/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,19 +133,25 @@ const layer = Layer.effect(

for (const item of yield* skill.all()) {
if (commands[item.name]) continue
const dir = item.location === "<built-in>" ? 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 === "<built-in>") 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: [],
}
Expand Down
14 changes: 11 additions & 3 deletions packages/opencode/src/skill/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,12 @@ export interface Interface {
readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]>
}

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,
Expand All @@ -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,
Expand Down Expand Up @@ -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 !== "<built-in>") yield* add(s, info.location, events, false)
return s.skills[name]
}
return yield* new NotFoundError({ name, available: Object.keys(s.skills).toSorted() })
})

Expand Down
29 changes: 28 additions & 1 deletion packages/opencode/test/tool/skill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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", () =>
Expand Down Expand Up @@ -90,6 +93,30 @@ Use this skill.
expect(result.output).toContain(`<skill_content name="tool-skill">`)
expect(result.output).toContain(`Base directory for this skill: ${skill}`)
expect(result.output).toContain(`<file>${file}</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.")
}),
)

Expand Down
Loading