diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index c1c6d0d6f2..6ee0b7c559 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -16,6 +16,7 @@ import * as Log from "@opencode-ai/core/util/log" import { Discovery } from "./discovery" import CUSTOMIZE_OPENCODE_SKILL_BODY from "./prompt/customize-opencode.md" with { type: "text" } import { isRecord } from "@/util/record" +import { serviceUse } from "@opencode-ai/core/effect/service-use" const log = Log.create({ service: "skill" }) const CLAUDE_EXTERNAL_DIR = ".claude" @@ -243,6 +244,8 @@ const loadSkills = Effect.fnUntraced(function* (state: State, discovered: Discov export class Service extends Context.Service()("@opencode/Skill") {} +export const use = serviceUse(Service) + export const layer = Layer.effect( Service, Effect.gen(function* () { diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index fc1f6bff6a..9b3c5e4575 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -8,14 +8,13 @@ import { Config } from "../../src/config/config" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Global } from "@opencode-ai/core/global" -import { provideInstance, provideTmpdirInstance, tmpdir } from "../fixture/fixture" +import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import path from "path" -import fs from "fs/promises" const node = CrossSpawnSpawner.defaultLayer -const it = testEffect(Layer.mergeAll(Skill.defaultLayer, node)) +const it = testEffect(Layer.mergeAll(Skill.defaultLayer, AppFileSystem.defaultLayer, node)) const itWithoutClaudeCodeSkills = testEffect( Layer.mergeAll( Skill.layer.pipe( @@ -26,6 +25,7 @@ const itWithoutClaudeCodeSkills = testEffect( Layer.provide(Global.layer), Layer.provide(RuntimeFlags.layer({ disableClaudeCodeSkills: true })), ), + AppFileSystem.defaultLayer, node, ), ) @@ -39,15 +39,18 @@ const itWithoutExternalSkills = testEffect( Layer.provide(Global.layer), Layer.provide(RuntimeFlags.layer({ disableExternalSkills: true })), ), + AppFileSystem.defaultLayer, node, ), ) -async function createGlobalSkill(homeDir: string) { - const skillDir = path.join(homeDir, ".claude", "skills", "global-test-skill") - await fs.mkdir(skillDir, { recursive: true }) - await Bun.write( - path.join(skillDir, "SKILL.md"), +const writeSkill = (dir: string, parts: string[], content: string) => + AppFileSystem.use.writeWithDirs(path.join(dir, ...parts, "SKILL.md"), content) + +const createGlobalSkill = (homeDir: string) => + writeSkill( + homeDir, + [".claude", "skills", "global-test-skill"], `--- name: global-test-skill description: A global skill from ~/.claude/skills for testing. @@ -58,7 +61,6 @@ description: A global skill from ~/.claude/skills for testing. This skill is loaded from the global home directory. `, ) -} const withHome = (home: string, self: Effect.Effect) => Effect.acquireUseRelease( @@ -75,14 +77,14 @@ const withHome = (home: string, self: Effect.Effect) => ) describe("skill", () => { - it.live("discovers skills from .opencode/skill/ directory", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - yield* Effect.promise(() => - Bun.write( - path.join(dir, ".opencode", "skill", "test-skill", "SKILL.md"), - `--- + it.instance( + "discovers skills from .opencode/skill/ directory", + Effect.gen(function* () { + const test = yield* TestInstance + yield* writeSkill( + test.directory, + [".opencode", "skill", "test-skill"], + `--- name: test-skill description: A test skill for verification. --- @@ -91,118 +93,111 @@ description: A test skill for verification. Instructions here. `, - ), - ) + ) - const skill = yield* Skill.Service - const list = (yield* skill.all()).filter((s) => s.location !== "") - expect(list.length).toBe(1) - const item = list.find((x) => x.name === "test-skill") - expect(item).toBeDefined() - expect(item!.description).toBe("A test skill for verification.") - expect(item!.location).toContain(path.join("skill", "test-skill", "SKILL.md")) - }), - { git: true }, - ), + const list = (yield* Skill.use.all()).filter((s) => s.location !== "") + expect(list.length).toBe(1) + const item = list.find((x) => x.name === "test-skill") + expect(item).toBeDefined() + expect(item!.description).toBe("A test skill for verification.") + expect(item!.location).toContain(path.join("skill", "test-skill", "SKILL.md")) + }), + { git: true }, ) - it.live("returns skill directories from Skill.dirs", () => - provideTmpdirInstance( - (dir) => - withHome( - dir, - Effect.gen(function* () { - yield* Effect.promise(() => - Bun.write( - path.join(dir, ".opencode", "skill", "dir-skill", "SKILL.md"), - `--- + it.instance( + "returns skill directories from Skill.dirs", + Effect.gen(function* () { + const test = yield* TestInstance + yield* withHome( + test.directory, + Effect.gen(function* () { + yield* writeSkill( + test.directory, + [".opencode", "skill", "dir-skill"], + `--- name: dir-skill description: Skill for dirs test. --- # Dir Skill `, - ), - ) + ) - const skill = yield* Skill.Service - const dirs = yield* skill.dirs() - expect(dirs).toContain(path.join(dir, ".opencode", "skill", "dir-skill")) - expect(dirs.length).toBe(1) - }), - ), - { git: true }, - ), + const dirs = yield* Skill.use.dirs() + expect(dirs).toContain(path.join(test.directory, ".opencode", "skill", "dir-skill")) + expect(dirs.length).toBe(1) + }), + ) + }), + { git: true }, ) - it.live("discovers multiple skills from .opencode/skill/ directory", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - yield* Effect.promise(() => - Promise.all([ - Bun.write( - path.join(dir, ".opencode", "skill", "skill-one", "SKILL.md"), - `--- + it.instance( + "discovers multiple skills from .opencode/skill/ directory", + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.all( + [ + writeSkill( + test.directory, + [".opencode", "skill", "skill-one"], + `--- name: skill-one description: First test skill. --- # Skill One `, - ), - Bun.write( - path.join(dir, ".opencode", "skill", "skill-two", "SKILL.md"), - `--- + ), + writeSkill( + test.directory, + [".opencode", "skill", "skill-two"], + `--- name: skill-two description: Second test skill. --- # Skill Two `, - ), - ]), - ) + ), + ], + ) - const skill = yield* Skill.Service - const list = (yield* skill.all()).filter((s) => s.location !== "") - expect(list.length).toBe(2) - expect(list.find((x) => x.name === "skill-one")).toBeDefined() - expect(list.find((x) => x.name === "skill-two")).toBeDefined() - }), - { git: true }, - ), + const list = (yield* Skill.use.all()).filter((s) => s.location !== "") + expect(list.length).toBe(2) + expect(list.find((x) => x.name === "skill-one")).toBeDefined() + expect(list.find((x) => x.name === "skill-two")).toBeDefined() + }), + { git: true }, ) - it.live("skips skills with missing frontmatter", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - yield* Effect.promise(() => - Bun.write( - path.join(dir, ".opencode", "skill", "no-frontmatter", "SKILL.md"), - `# No Frontmatter + it.instance( + "skips skills with missing frontmatter", + Effect.gen(function* () { + const test = yield* TestInstance + yield* writeSkill( + test.directory, + [".opencode", "skill", "no-frontmatter"], + `# No Frontmatter Just some content without YAML frontmatter. `, - ), - ) + ) - const skill = yield* Skill.Service - expect((yield* skill.all()).filter((s) => s.location !== "")).toEqual([]) - }), - { git: true }, - ), + expect((yield* Skill.use.all()).filter((s) => s.location !== "")).toEqual([]) + }), + { git: true }, ) - it.live("discovers skills without descriptions", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - yield* Effect.promise(() => - Bun.write( - path.join(dir, ".opencode", "skill", "manual-skill", "SKILL.md"), - `--- + it.instance( + "discovers skills without descriptions", + Effect.gen(function* () { + const test = yield* TestInstance + yield* writeSkill( + test.directory, + [".opencode", "skill", "manual-skill"], + `--- name: manual-skill --- @@ -210,98 +205,81 @@ name: manual-skill Instructions here. `, - ), - ) + ) - const skill = yield* Skill.Service - const list = (yield* skill.all()).filter((s) => s.location !== "") - expect(list.length).toBe(1) - const item = list.find((x) => x.name === "manual-skill") - expect(item).toBeDefined() - expect(item!.description).toBeUndefined() - expect(Skill.fmt(list, { verbose: false })).toBe("No skills are currently available.") - expect(Skill.fmt(list, { verbose: true })).toBe("No skills are currently available.") - }), - { git: true }, - ), + const list = (yield* Skill.use.all()).filter((s) => s.location !== "") + expect(list.length).toBe(1) + const item = list.find((x) => x.name === "manual-skill") + expect(item).toBeDefined() + expect(item!.description).toBeUndefined() + expect(Skill.fmt(list, { verbose: false })).toBe("No skills are currently available.") + expect(Skill.fmt(list, { verbose: true })).toBe("No skills are currently available.") + }), + { git: true }, ) - it.live("discovers skills from .claude/skills/ directory", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - yield* Effect.promise(() => - Bun.write( - path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"), - `--- + it.instance( + "discovers skills from .claude/skills/ directory", + Effect.gen(function* () { + const test = yield* TestInstance + yield* writeSkill( + test.directory, + [".claude", "skills", "claude-skill"], + `--- name: claude-skill description: A skill in the .claude/skills directory. --- # Claude Skill `, - ), - ) - - const skill = yield* Skill.Service - const list = (yield* skill.all()).filter((s) => s.location !== "") - expect(list.length).toBe(1) - const item = list.find((x) => x.name === "claude-skill") - expect(item).toBeDefined() - expect(item!.location).toContain(path.join(".claude", "skills", "claude-skill", "SKILL.md")) - }), - { git: true }, - ), - ) - - it.live("discovers global skills from ~/.claude/skills/ directory", () => - Effect.gen(function* () { - const tmp = yield* Effect.acquireRelease( - Effect.promise(() => tmpdir({ git: true })), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ) + const list = (yield* Skill.use.all()).filter((s) => s.location !== "") + expect(list.length).toBe(1) + const item = list.find((x) => x.name === "claude-skill") + expect(item).toBeDefined() + expect(item!.location).toContain(path.join(".claude", "skills", "claude-skill", "SKILL.md")) + }), + { git: true }, + ) + + it.instance( + "discovers global skills from ~/.claude/skills/ directory", + Effect.gen(function* () { + const test = yield* TestInstance yield* withHome( - tmp.path, + test.directory, Effect.gen(function* () { - yield* Effect.promise(() => createGlobalSkill(tmp.path)) - yield* Effect.gen(function* () { - const skill = yield* Skill.Service - const list = (yield* skill.all()).filter((s) => s.location !== "") - expect(list.length).toBe(1) - expect(list[0].name).toBe("global-test-skill") - expect(list[0].description).toBe("A global skill from ~/.claude/skills for testing.") - expect(list[0].location).toContain(path.join(".claude", "skills", "global-test-skill", "SKILL.md")) - }).pipe(provideInstance(tmp.path)) + yield* createGlobalSkill(test.directory) + const list = (yield* Skill.use.all()).filter((s) => s.location !== "") + expect(list.length).toBe(1) + expect(list[0].name).toBe("global-test-skill") + expect(list[0].description).toBe("A global skill from ~/.claude/skills for testing.") + expect(list[0].location).toContain(path.join(".claude", "skills", "global-test-skill", "SKILL.md")) }), ) }), + { git: true }, ) - it.live("returns empty array when no skills exist", () => - provideTmpdirInstance( - () => - Effect.gen(function* () { - const skill = yield* Skill.Service - expect((yield* skill.all()).filter((s) => s.location !== "")).toEqual([]) - }), - { git: true }, - ), + it.instance( + "returns empty array when no skills exist", + Effect.gen(function* () { + expect((yield* Skill.use.all()).filter((s) => s.location !== "")).toEqual([]) + }), + { git: true }, ) - it.live("fails with typed error when requiring a missing skill", () => - provideTmpdirInstance( - () => - Effect.gen(function* () { - const skill = yield* Skill.Service - const error = yield* Effect.flip(skill.require("missing-skill")) - expect(error).toBeInstanceOf(Skill.NotFoundError) - expect(error._tag).toBe("Skill.NotFoundError") - expect(error.name).toBe("missing-skill") - expect(error.message).toContain('Skill "missing-skill" not found.') - }), - { git: true }, - ), + it.instance( + "fails with typed error when requiring a missing skill", + Effect.gen(function* () { + const error = yield* Effect.flip(Skill.use.require("missing-skill")) + expect(error).toBeInstanceOf(Skill.NotFoundError) + expect(error._tag).toBe("Skill.NotFoundError") + expect(error.name).toBe("missing-skill") + expect(error.message).toContain('Skill "missing-skill" not found.') + }), + { git: true }, ) it.effect("exposes tagged expected skill failure classes", () => @@ -320,50 +298,42 @@ description: A skill in the .claude/skills directory. }), ) - it.live("discovers skills from .agents/skills/ directory", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - yield* Effect.promise(() => - Bun.write( - path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"), - `--- + it.instance( + "discovers skills from .agents/skills/ directory", + Effect.gen(function* () { + const test = yield* TestInstance + yield* writeSkill( + test.directory, + [".agents", "skills", "agent-skill"], + `--- name: agent-skill description: A skill in the .agents/skills directory. --- # Agent Skill `, - ), - ) - - const skill = yield* Skill.Service - const list = (yield* skill.all()).filter((s) => s.location !== "") - expect(list.length).toBe(1) - const item = list.find((x) => x.name === "agent-skill") - expect(item).toBeDefined() - expect(item!.location).toContain(path.join(".agents", "skills", "agent-skill", "SKILL.md")) - }), - { git: true }, - ), - ) - - it.live("discovers global skills from ~/.agents/skills/ directory", () => - Effect.gen(function* () { - const tmp = yield* Effect.acquireRelease( - Effect.promise(() => tmpdir({ git: true })), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ) + const list = (yield* Skill.use.all()).filter((s) => s.location !== "") + expect(list.length).toBe(1) + const item = list.find((x) => x.name === "agent-skill") + expect(item).toBeDefined() + expect(item!.location).toContain(path.join(".agents", "skills", "agent-skill", "SKILL.md")) + }), + { git: true }, + ) + + it.instance( + "discovers global skills from ~/.agents/skills/ directory", + Effect.gen(function* () { + const test = yield* TestInstance yield* withHome( - tmp.path, + test.directory, Effect.gen(function* () { - const skillDir = path.join(tmp.path, ".agents", "skills", "global-agent-skill") - yield* Effect.promise(() => fs.mkdir(skillDir, { recursive: true })) - yield* Effect.promise(() => - Bun.write( - path.join(skillDir, "SKILL.md"), - `--- + yield* writeSkill( + test.directory, + [".agents", "skills", "global-agent-skill"], + `--- name: global-agent-skill description: A global skill from ~/.agents/skills for testing. --- @@ -372,198 +342,198 @@ description: A global skill from ~/.agents/skills for testing. This skill is loaded from the global home directory. `, - ), ) - yield* Effect.gen(function* () { - const skill = yield* Skill.Service - const list = (yield* skill.all()).filter((s) => s.location !== "") - expect(list.length).toBe(1) - expect(list[0].name).toBe("global-agent-skill") - expect(list[0].description).toBe("A global skill from ~/.agents/skills for testing.") - expect(list[0].location).toContain(path.join(".agents", "skills", "global-agent-skill", "SKILL.md")) - }).pipe(provideInstance(tmp.path)) + const list = (yield* Skill.use.all()).filter((s) => s.location !== "") + expect(list.length).toBe(1) + expect(list[0].name).toBe("global-agent-skill") + expect(list[0].description).toBe("A global skill from ~/.agents/skills for testing.") + expect(list[0].location).toContain(path.join(".agents", "skills", "global-agent-skill", "SKILL.md")) }), ) }), + { git: true }, ) - it.live("discovers skills from both .claude/skills/ and .agents/skills/", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - yield* Effect.promise(() => - Promise.all([ - Bun.write( - path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"), - `--- + it.instance( + "discovers skills from both .claude/skills/ and .agents/skills/", + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.all( + [ + writeSkill( + test.directory, + [".claude", "skills", "claude-skill"], + `--- name: claude-skill description: A skill in the .claude/skills directory. --- # Claude Skill `, - ), - Bun.write( - path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"), - `--- + ), + writeSkill( + test.directory, + [".agents", "skills", "agent-skill"], + `--- name: agent-skill description: A skill in the .agents/skills directory. --- # Agent Skill `, - ), - ]), - ) + ), + ], + ) - const skill = yield* Skill.Service - const list = (yield* skill.all()).filter((s) => s.location !== "") - expect(list.length).toBe(2) - expect(list.find((x) => x.name === "claude-skill")).toBeDefined() - expect(list.find((x) => x.name === "agent-skill")).toBeDefined() - }), - { git: true }, - ), + const list = (yield* Skill.use.all()).filter((s) => s.location !== "") + expect(list.length).toBe(2) + expect(list.find((x) => x.name === "claude-skill")).toBeDefined() + expect(list.find((x) => x.name === "agent-skill")).toBeDefined() + }), + { git: true }, ) - itWithoutClaudeCodeSkills.live("skips Claude Code skills when disabled", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - yield* Effect.promise(() => - Promise.all([ - Bun.write( - path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"), - `--- + itWithoutClaudeCodeSkills.instance( + "skips Claude Code skills when disabled", + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.all( + [ + writeSkill( + test.directory, + [".claude", "skills", "claude-skill"], + `--- name: claude-skill description: A skill in the .claude/skills directory. --- # Claude Skill `, - ), - Bun.write( - path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"), - `--- + ), + writeSkill( + test.directory, + [".agents", "skills", "agent-skill"], + `--- name: agent-skill description: A skill in the .agents/skills directory. --- # Agent Skill `, - ), - ]), - ) + ), + ], + ) - const skill = yield* Skill.Service - const list = (yield* skill.all()).filter((s) => s.location !== "") - expect(list.map((s) => s.name)).toEqual(["agent-skill"]) - }), - { git: true }, - ), + const list = (yield* Skill.use.all()).filter((s) => s.location !== "") + expect(list.map((s) => s.name)).toEqual(["agent-skill"]) + }), + { git: true }, ) - itWithoutExternalSkills.live("skips external skill directories when disabled", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - yield* Effect.promise(() => - Promise.all([ - Bun.write( - path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"), - `--- + itWithoutExternalSkills.instance( + "skips external skill directories when disabled", + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.all( + [ + writeSkill( + test.directory, + [".claude", "skills", "claude-skill"], + `--- name: claude-skill description: A skill in the .claude/skills directory. --- # Claude Skill `, - ), - Bun.write( - path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"), - `--- + ), + writeSkill( + test.directory, + [".agents", "skills", "agent-skill"], + `--- name: agent-skill description: A skill in the .agents/skills directory. --- # Agent Skill `, - ), - Bun.write( - path.join(dir, ".opencode", "skill", "opencode-skill", "SKILL.md"), - `--- + ), + writeSkill( + test.directory, + [".opencode", "skill", "opencode-skill"], + `--- name: opencode-skill description: A skill in the .opencode/skill directory. --- # OpenCode Skill `, - ), - ]), - ) + ), + ], + ) - const skill = yield* Skill.Service - const list = (yield* skill.all()).filter((s) => s.location !== "") - expect(list.map((s) => s.name)).toEqual(["opencode-skill"]) - }), - { git: true }, - ), + const list = (yield* Skill.use.all()).filter((s) => s.location !== "") + expect(list.map((s) => s.name)).toEqual(["opencode-skill"]) + }), + { git: true }, ) - it.live("properly resolves directories that skills live in", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - yield* Effect.promise(() => - Promise.all([ - Bun.write( - path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"), - `--- + it.instance( + "properly resolves directories that skills live in", + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.all( + [ + writeSkill( + test.directory, + [".claude", "skills", "claude-skill"], + `--- name: claude-skill description: A skill in the .claude/skills directory. --- # Claude Skill `, - ), - Bun.write( - path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"), - `--- + ), + writeSkill( + test.directory, + [".agents", "skills", "agent-skill"], + `--- name: agent-skill description: A skill in the .agents/skills directory. --- # Agent Skill `, - ), - Bun.write( - path.join(dir, ".opencode", "skill", "agent-skill", "SKILL.md"), - `--- + ), + writeSkill( + test.directory, + [".opencode", "skill", "agent-skill"], + `--- name: opencode-skill description: A skill in the .opencode/skill directory. --- # OpenCode Skill `, - ), - Bun.write( - path.join(dir, ".opencode", "skills", "agent-skill", "SKILL.md"), - `--- + ), + writeSkill( + test.directory, + [".opencode", "skills", "agent-skill"], + `--- name: opencode-skill description: A skill in the .opencode/skills directory. --- # OpenCode Skill `, - ), - ]), - ) + ), + ], + ) - const skill = yield* Skill.Service - expect((yield* skill.dirs()).length).toBe(4) - }), - { git: true }, - ), + expect((yield* Skill.use.dirs()).length).toBe(4) + }), + { git: true }, ) })