diff --git a/packages/opencode/src/cli/cmd/debug/skill.ts b/packages/opencode/src/cli/cmd/debug/skill.ts
index ebe3df1808..79179411b6 100644
--- a/packages/opencode/src/cli/cmd/debug/skill.ts
+++ b/packages/opencode/src/cli/cmd/debug/skill.ts
@@ -1,4 +1,6 @@
import { EOL } from "os"
+import { Effect } from "effect"
+import { AppRuntime } from "@/effect/app-runtime"
import { Skill } from "../../../skill"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
@@ -9,7 +11,12 @@ export const SkillCommand = cmd({
builder: (yargs) => yargs,
async handler() {
await bootstrap(process.cwd(), async () => {
- const skills = await Skill.all()
+ const skills = await AppRuntime.runPromise(
+ Effect.gen(function* () {
+ const skill = yield* Skill.Service
+ return yield* skill.all()
+ }),
+ )
process.stdout.write(JSON.stringify(skills, null, 2) + EOL)
})
},
diff --git a/packages/opencode/src/server/instance/index.ts b/packages/opencode/src/server/instance/index.ts
index 2acc424e4e..2879d9aa12 100644
--- a/packages/opencode/src/server/instance/index.ts
+++ b/packages/opencode/src/server/instance/index.ts
@@ -24,6 +24,7 @@ import { ProviderRoutes } from "./provider"
import { EventRoutes } from "./event"
import { WorkspaceRouterMiddleware } from "./middleware"
import { AppRuntime } from "@/effect/app-runtime"
+import { Effect } from "effect"
export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
new Hono()
@@ -215,7 +216,12 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
},
}),
async (c) => {
- const skills = await Skill.all()
+ const skills = await AppRuntime.runPromise(
+ Effect.gen(function* () {
+ const skill = yield* Skill.Service
+ return yield* skill.all()
+ }),
+ )
return c.json(skills)
},
)
diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts
index be74c0b342..6c4f290a08 100644
--- a/packages/opencode/src/skill/index.ts
+++ b/packages/opencode/src/skill/index.ts
@@ -7,7 +7,6 @@ import { NamedError } from "@opencode-ai/util/error"
import type { Agent } from "@/agent/agent"
import { Bus } from "@/bus"
import { InstanceState } from "@/effect/instance-state"
-import { makeRuntime } from "@/effect/run-service"
import { Flag } from "@/flag/flag"
import { Global } from "@/global"
import { Permission } from "@/permission"
@@ -262,22 +261,4 @@ export namespace Skill {
.map((skill) => `- **${skill.name}**: ${skill.description}`),
].join("\n")
}
-
- const { runPromise } = makeRuntime(Service, defaultLayer)
-
- export async function get(name: string) {
- return runPromise((skill) => skill.get(name))
- }
-
- export async function all() {
- return runPromise((skill) => skill.all())
- }
-
- export async function dirs() {
- return runPromise((skill) => skill.dirs())
- }
-
- export async function available(agent?: Agent.Info) {
- return runPromise((skill) => skill.available(agent))
- }
}
diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts
index 12e16f86a1..0a14e30b7d 100644
--- a/packages/opencode/test/skill/skill.test.ts
+++ b/packages/opencode/test/skill/skill.test.ts
@@ -1,13 +1,17 @@
-import { afterEach, test, expect } from "bun:test"
+import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
+import { describe, expect } from "bun:test"
+import { Effect, Layer } from "effect"
import { Skill } from "../../src/skill"
-import { Instance } from "../../src/project/instance"
-import { tmpdir } from "../fixture/fixture"
+import { provideInstance, provideTmpdirInstance, tmpdir } from "../fixture/fixture"
+import { testEffect } from "../lib/effect"
import path from "path"
import fs from "fs/promises"
-afterEach(async () => {
- await Instance.disposeAll()
-})
+const node = NodeChildProcessSpawner.layer.pipe(
+ Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)),
+)
+
+const it = testEffect(Layer.mergeAll(Skill.defaultLayer, node))
async function createGlobalSkill(homeDir: string) {
const skillDir = path.join(homeDir, ".claude", "skills", "global-test-skill")
@@ -26,14 +30,29 @@ This skill is loaded from the global home directory.
)
}
-test("discovers skills from .opencode/skill/ directory", async () => {
- await using tmp = await tmpdir({
- git: true,
- init: async (dir) => {
- const skillDir = path.join(dir, ".opencode", "skill", "test-skill")
- await Bun.write(
- path.join(skillDir, "SKILL.md"),
- `---
+const withHome = (home: string, self: Effect.Effect) =>
+ Effect.acquireUseRelease(
+ Effect.sync(() => {
+ const prev = process.env.OPENCODE_TEST_HOME
+ process.env.OPENCODE_TEST_HOME = home
+ return prev
+ }),
+ () => self,
+ (prev) =>
+ Effect.sync(() => {
+ process.env.OPENCODE_TEST_HOME = prev
+ }),
+ )
+
+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"),
+ `---
name: test-skill
description: A test skill for verification.
---
@@ -42,230 +61,217 @@ description: A test skill for verification.
Instructions here.
`,
- )
- },
- })
+ ),
+ )
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const skills = await Skill.all()
- expect(skills.length).toBe(1)
- const testSkill = skills.find((s) => s.name === "test-skill")
- expect(testSkill).toBeDefined()
- expect(testSkill!.description).toBe("A test skill for verification.")
- expect(testSkill!.location).toContain(path.join("skill", "test-skill", "SKILL.md"))
- },
- })
-})
+ const skill = yield* Skill.Service
+ const list = yield* skill.all()
+ 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 },
+ ),
+ )
-test("returns skill directories from Skill.dirs", async () => {
- await using tmp = await tmpdir({
- git: true,
- init: async (dir) => {
- const skillDir = path.join(dir, ".opencode", "skill", "dir-skill")
- await Bun.write(
- path.join(skillDir, "SKILL.md"),
- `---
+ 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"),
+ `---
name: dir-skill
description: Skill for dirs test.
---
# Dir Skill
`,
- )
- },
- })
+ ),
+ )
- const home = process.env.OPENCODE_TEST_HOME
- process.env.OPENCODE_TEST_HOME = tmp.path
+ 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 },
+ ),
+ )
- try {
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const dirs = await Skill.dirs()
- const skillDir = path.join(tmp.path, ".opencode", "skill", "dir-skill")
- expect(dirs).toContain(skillDir)
- expect(dirs.length).toBe(1)
- },
- })
- } finally {
- process.env.OPENCODE_TEST_HOME = home
- }
-})
-
-test("discovers multiple skills from .opencode/skill/ directory", async () => {
- await using tmp = await tmpdir({
- git: true,
- init: async (dir) => {
- const skillDir1 = path.join(dir, ".opencode", "skill", "skill-one")
- const skillDir2 = path.join(dir, ".opencode", "skill", "skill-two")
- await Bun.write(
- path.join(skillDir1, "SKILL.md"),
- `---
+ 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"),
+ `---
name: skill-one
description: First test skill.
---
# Skill One
`,
- )
- await Bun.write(
- path.join(skillDir2, "SKILL.md"),
- `---
+ ),
+ Bun.write(
+ path.join(dir, ".opencode", "skill", "skill-two", "SKILL.md"),
+ `---
name: skill-two
description: Second test skill.
---
# Skill Two
`,
- )
- },
- })
+ ),
+ ]),
+ )
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const skills = await Skill.all()
- expect(skills.length).toBe(2)
- expect(skills.find((s) => s.name === "skill-one")).toBeDefined()
- expect(skills.find((s) => s.name === "skill-two")).toBeDefined()
- },
- })
-})
+ const skill = yield* Skill.Service
+ const list = yield* skill.all()
+ 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 },
+ ),
+ )
-test("skips skills with missing frontmatter", async () => {
- await using tmp = await tmpdir({
- git: true,
- init: async (dir) => {
- const skillDir = path.join(dir, ".opencode", "skill", "no-frontmatter")
- await Bun.write(
- path.join(skillDir, "SKILL.md"),
- `# No Frontmatter
+ 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
Just some content without YAML frontmatter.
`,
- )
- },
- })
+ ),
+ )
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const skills = await Skill.all()
- expect(skills).toEqual([])
- },
- })
-})
+ const skill = yield* Skill.Service
+ expect(yield* skill.all()).toEqual([])
+ }),
+ { git: true },
+ ),
+ )
-test("discovers skills from .claude/skills/ directory", async () => {
- await using tmp = await tmpdir({
- git: true,
- init: async (dir) => {
- const skillDir = path.join(dir, ".claude", "skills", "claude-skill")
- await Bun.write(
- path.join(skillDir, "SKILL.md"),
- `---
+ 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"),
+ `---
name: claude-skill
description: A skill in the .claude/skills directory.
---
# Claude Skill
`,
+ ),
+ )
+
+ const skill = yield* Skill.Service
+ const list = yield* skill.all()
+ 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]()),
)
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const skills = await Skill.all()
- expect(skills.length).toBe(1)
- const claudeSkill = skills.find((s) => s.name === "claude-skill")
- expect(claudeSkill).toBeDefined()
- expect(claudeSkill!.location).toContain(path.join(".claude", "skills", "claude-skill", "SKILL.md"))
- },
- })
-})
+ yield* withHome(
+ tmp.path,
+ Effect.gen(function* () {
+ yield* Effect.promise(() => createGlobalSkill(tmp.path))
+ yield* Effect.gen(function* () {
+ const skill = yield* Skill.Service
+ const list = yield* skill.all()
+ 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))
+ }),
+ )
+ }),
+ )
-test("discovers global skills from ~/.claude/skills/ directory", async () => {
- await using tmp = await tmpdir({ git: true })
+ it.live("returns empty array when no skills exist", () =>
+ provideTmpdirInstance(
+ () =>
+ Effect.gen(function* () {
+ const skill = yield* Skill.Service
+ expect(yield* skill.all()).toEqual([])
+ }),
+ { git: true },
+ ),
+ )
- const originalHome = process.env.OPENCODE_TEST_HOME
- process.env.OPENCODE_TEST_HOME = tmp.path
-
- try {
- await createGlobalSkill(tmp.path)
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const skills = await Skill.all()
- expect(skills.length).toBe(1)
- expect(skills[0].name).toBe("global-test-skill")
- expect(skills[0].description).toBe("A global skill from ~/.claude/skills for testing.")
- expect(skills[0].location).toContain(path.join(".claude", "skills", "global-test-skill", "SKILL.md"))
- },
- })
- } finally {
- process.env.OPENCODE_TEST_HOME = originalHome
- }
-})
-
-test("returns empty array when no skills exist", async () => {
- await using tmp = await tmpdir({ git: true })
-
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const skills = await Skill.all()
- expect(skills).toEqual([])
- },
- })
-})
-
-test("discovers skills from .agents/skills/ directory", async () => {
- await using tmp = await tmpdir({
- git: true,
- init: async (dir) => {
- const skillDir = path.join(dir, ".agents", "skills", "agent-skill")
- await Bun.write(
- path.join(skillDir, "SKILL.md"),
- `---
+ 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"),
+ `---
name: agent-skill
description: A skill in the .agents/skills directory.
---
# Agent Skill
`,
+ ),
+ )
+
+ const skill = yield* Skill.Service
+ const list = yield* skill.all()
+ 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]()),
)
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const skills = await Skill.all()
- expect(skills.length).toBe(1)
- const agentSkill = skills.find((s) => s.name === "agent-skill")
- expect(agentSkill).toBeDefined()
- expect(agentSkill!.location).toContain(path.join(".agents", "skills", "agent-skill", "SKILL.md"))
- },
- })
-})
-
-test("discovers global skills from ~/.agents/skills/ directory", async () => {
- await using tmp = await tmpdir({ git: true })
-
- const originalHome = process.env.OPENCODE_TEST_HOME
- process.env.OPENCODE_TEST_HOME = tmp.path
-
- try {
- const skillDir = path.join(tmp.path, ".agents", "skills", "global-agent-skill")
- await fs.mkdir(skillDir, { recursive: true })
- await Bun.write(
- path.join(skillDir, "SKILL.md"),
- `---
+ yield* withHome(
+ tmp.path,
+ 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"),
+ `---
name: global-agent-skill
description: A global skill from ~/.agents/skills for testing.
---
@@ -274,119 +280,114 @@ description: A global skill from ~/.agents/skills for testing.
This skill is loaded from the global home directory.
`,
- )
+ ),
+ )
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const skills = await Skill.all()
- expect(skills.length).toBe(1)
- expect(skills[0].name).toBe("global-agent-skill")
- expect(skills[0].description).toBe("A global skill from ~/.agents/skills for testing.")
- expect(skills[0].location).toContain(path.join(".agents", "skills", "global-agent-skill", "SKILL.md"))
- },
- })
- } finally {
- process.env.OPENCODE_TEST_HOME = originalHome
- }
-})
+ yield* Effect.gen(function* () {
+ const skill = yield* Skill.Service
+ const list = yield* skill.all()
+ 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))
+ }),
+ )
+ }),
+ )
-test("discovers skills from both .claude/skills/ and .agents/skills/", async () => {
- await using tmp = await tmpdir({
- git: true,
- init: async (dir) => {
- const claudeDir = path.join(dir, ".claude", "skills", "claude-skill")
- const agentDir = path.join(dir, ".agents", "skills", "agent-skill")
- await Bun.write(
- path.join(claudeDir, "SKILL.md"),
- `---
+ 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"),
+ `---
name: claude-skill
description: A skill in the .claude/skills directory.
---
# Claude Skill
`,
- )
- await Bun.write(
- path.join(agentDir, "SKILL.md"),
- `---
+ ),
+ Bun.write(
+ path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"),
+ `---
name: agent-skill
description: A skill in the .agents/skills directory.
---
# Agent Skill
`,
- )
- },
- })
+ ),
+ ]),
+ )
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const skills = await Skill.all()
- expect(skills.length).toBe(2)
- expect(skills.find((s) => s.name === "claude-skill")).toBeDefined()
- expect(skills.find((s) => s.name === "agent-skill")).toBeDefined()
- },
- })
-})
+ const skill = yield* Skill.Service
+ const list = yield* skill.all()
+ 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 },
+ ),
+ )
-test("properly resolves directories that skills live in", async () => {
- await using tmp = await tmpdir({
- git: true,
- init: async (dir) => {
- const opencodeSkillDir = path.join(dir, ".opencode", "skill", "agent-skill")
- const opencodeSkillsDir = path.join(dir, ".opencode", "skills", "agent-skill")
- const claudeDir = path.join(dir, ".claude", "skills", "claude-skill")
- const agentDir = path.join(dir, ".agents", "skills", "agent-skill")
- await Bun.write(
- path.join(claudeDir, "SKILL.md"),
- `---
+ 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"),
+ `---
name: claude-skill
description: A skill in the .claude/skills directory.
---
# Claude Skill
`,
- )
- await Bun.write(
- path.join(agentDir, "SKILL.md"),
- `---
+ ),
+ Bun.write(
+ path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"),
+ `---
name: agent-skill
description: A skill in the .agents/skills directory.
---
# Agent Skill
`,
- )
- await Bun.write(
- path.join(opencodeSkillDir, "SKILL.md"),
- `---
+ ),
+ Bun.write(
+ path.join(dir, ".opencode", "skill", "agent-skill", "SKILL.md"),
+ `---
name: opencode-skill
description: A skill in the .opencode/skill directory.
---
# OpenCode Skill
`,
- )
- await Bun.write(
- path.join(opencodeSkillsDir, "SKILL.md"),
- `---
+ ),
+ Bun.write(
+ path.join(dir, ".opencode", "skills", "agent-skill", "SKILL.md"),
+ `---
name: opencode-skill
description: A skill in the .opencode/skills directory.
---
# OpenCode Skill
`,
- )
- },
- })
+ ),
+ ]),
+ )
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const dirs = await Skill.dirs()
- expect(dirs.length).toBe(4)
- },
- })
+ const skill = yield* Skill.Service
+ expect((yield* skill.dirs()).length).toBe(4)
+ }),
+ { git: true },
+ ),
+ )
})