From 49c98c7b4b834a016e8ec9545df2bb1c4cbed200 Mon Sep 17 00:00:00 2001 From: Sami Jawhar Date: Fri, 3 Apr 2026 20:56:47 +0000 Subject: [PATCH 1/2] fix(skill): read plugin-modified config for skills.paths discovery Plugin config() hooks that mutate cfg.skills.paths (e.g. superpowers) were invisible to skill discovery because each service's makeRuntime creates a separate InstanceState scope. Plugin.config() now exposes the post-hook config as the single source of truth. --- packages/opencode/src/plugin/index.ts | 33 +++++++++++++++++++++++++-- packages/opencode/src/skill/index.ts | 6 ++++- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 9f618eff8cad..6296ee20cebd 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -20,6 +20,7 @@ import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cl import { Effect, Layer, Context, Stream } from "effect" import { EffectBridge } from "@/effect/bridge" import { InstanceState } from "@/effect/instance-state" +import { makeRuntime } from "@/effect/run-service" import { errorMessage } from "@/util/error" import { PluginLoader } from "./loader" import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared" @@ -31,6 +32,7 @@ export namespace Plugin { type State = { hooks: Hooks[] + config: Config.Info } // Hook names that follow the (input, output) => Promise trigger pattern @@ -49,6 +51,7 @@ export namespace Plugin { output: Output, ) => Effect.Effect readonly list: () => Effect.Effect + readonly config: () => Effect.Effect readonly init: () => Effect.Effect } @@ -253,7 +256,7 @@ export namespace Plugin { Effect.forkScoped, ) - return { hooks } + return { hooks, config: cfg } }), ) @@ -277,13 +280,39 @@ export namespace Plugin { return s.hooks }) + const cfg = Effect.fn("Plugin.config")(function* () { + const s = yield* InstanceState.get(state) + return s.config + }) + const init = Effect.fn("Plugin.init")(function* () { yield* InstanceState.get(state) }) - return Service.of({ trigger, list, init }) + return Service.of({ trigger, list, config: cfg, init }) }), ) export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer)) + const { runPromise } = makeRuntime(Service, defaultLayer) + + export async function trigger< + Name extends TriggerName, + Input = Parameters[Name]>[0], + Output = Parameters[Name]>[1], + >(name: Name, input: Input, output: Output): Promise { + return runPromise((svc) => svc.trigger(name, input, output)) + } + + export async function list(): Promise { + return runPromise((svc) => svc.list()) + } + + export async function init() { + return runPromise((svc) => svc.init()) + } + + export async function config(): Promise { + return runPromise((svc) => svc.config()) + } } diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 79b426c69ca8..9088156abf91 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -163,7 +163,11 @@ export namespace Skill { yield* scan(state, bus, dir, OPENCODE_SKILL_PATTERN) } - const cfg = yield* config.get() + // Read config that includes plugin config() hook mutations. + // Plugin.config() returns the config after all plugins' config() hooks + // have run, so skills.paths includes plugin-registered directories. + const { Plugin } = yield* Effect.promise(() => import("../plugin")) + const cfg = yield* Effect.promise(() => Plugin.config()) for (const item of cfg.skills?.paths ?? []) { const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded) From 5f6adaac4f78df817e0d0f16e9a48540018ebe2c Mon Sep 17 00:00:00 2001 From: Sami Jawhar Date: Sun, 12 Apr 2026 22:05:53 +0000 Subject: [PATCH 2/2] test(skill): behavioral test for plugin config hook skill paths discovery --- packages/opencode/src/plugin/index.ts | 7 +- packages/opencode/src/skill/index.ts | 8 +- .../opencode/test/plugin/skill-paths.test.ts | 100 ++++++++++++++++++ 3 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 packages/opencode/test/plugin/skill-paths.test.ts diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 6296ee20cebd..f0f1be259b7c 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -131,7 +131,7 @@ export namespace Plugin { Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`, } : undefined, - fetch: async (...args) => (await Server.Default()).app.fetch(...args), + fetch: async (...args) => Server.Default().app.fetch(...args), }) const cfg = yield* config.get() const input: PluginInput = { @@ -293,7 +293,10 @@ export namespace Plugin { }), ) - export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer)) + export const defaultLayer = layer.pipe( + Layer.provide(Bus.layer), + Layer.provide(Config.defaultLayer), + ) as Layer.Layer const { runPromise } = makeRuntime(Service, defaultLayer) export async function trigger< diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index 9088156abf91..c37b518e6d59 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -7,6 +7,7 @@ import { NamedError } from "@opencode-ai/shared/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" @@ -238,7 +239,12 @@ export namespace Skill { Layer.provide(Config.defaultLayer), Layer.provide(Bus.layer), Layer.provide(AppFileSystem.defaultLayer), - ) + ) as Layer.Layer + const { runPromise } = makeRuntime(Service, defaultLayer) + + export async function all() { + return runPromise((svc) => svc.all()) + } export function fmt(list: Info[], opts: { verbose: boolean }) { if (list.length === 0) return "No skills are currently available." diff --git a/packages/opencode/test/plugin/skill-paths.test.ts b/packages/opencode/test/plugin/skill-paths.test.ts new file mode 100644 index 000000000000..3ae78fdf70da --- /dev/null +++ b/packages/opencode/test/plugin/skill-paths.test.ts @@ -0,0 +1,100 @@ +import { afterEach, expect, test } from "bun:test" +import path from "path" +import fs from "fs/promises" +import { tmpdir } from "../fixture/fixture" + +const disable = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS +process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1" + +const { Instance } = await import("../../src/project/instance") +const { Skill } = await import("../../src/skill") + +afterEach(async () => { + if (disable === undefined) delete process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS + else process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = disable + await Instance.disposeAll() +}) + +test("plugin config hook registers skill paths that Skill.all() discovers", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + // Create a skill in an external directory (not .opencode/skill/) + const ext = path.join(dir, "plugin-skills", "my-plugin-skill") + await fs.mkdir(ext, { recursive: true }) + await Bun.write( + path.join(ext, "SKILL.md"), + [ + "---", + "name: plugin-injected-skill", + "description: A skill registered via plugin config hook.", + "---", + "", + "# Plugin Skill", + "", + "This was injected by a plugin's config hook.", + "", + ].join("\n"), + ) + + // Create a plugin that adds the external dir to skills.paths + const pluginDir = path.join(dir, ".opencode", "plugin") + await fs.mkdir(pluginDir, { recursive: true }) + const ext_paths = JSON.stringify(path.join(dir, "plugin-skills")) + await Bun.write( + path.join(pluginDir, "skill-path-plugin.ts"), + [ + "export default {", + ' id: "demo.skill-paths",', + " server: async () => ({", + " config: async (cfg) => {", + " if (!cfg.skills) cfg.skills = {}", + " if (!cfg.skills.paths) cfg.skills.paths = []", + ` cfg.skills.paths.push(${ext_paths})`, + " },", + " }),", + "}", + "", + ].join("\n"), + ) + }, + }) + + const skills = await Instance.provide({ + directory: tmp.path, + fn: () => Skill.all(), + }) + + const found = skills.find((s) => s.name === "plugin-injected-skill") + expect(found).toBeDefined() + expect(found!.description).toBe("A skill registered via plugin config hook.") +}, 30000) + +test("without the plugin, skill in external dir is NOT discovered", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + // Same external skill directory, but NO plugin to register it + const ext = path.join(dir, "plugin-skills", "orphan-skill") + await fs.mkdir(ext, { recursive: true }) + await Bun.write( + path.join(ext, "SKILL.md"), + [ + "---", + "name: orphan-skill", + "description: This skill should NOT be discovered.", + "---", + "", + "# Orphan", + "", + ].join("\n"), + ) + }, + }) + + const skills = await Instance.provide({ + directory: tmp.path, + fn: () => Skill.all(), + }) + + const found = skills.find((s) => s.name === "orphan-skill") + expect(found).toBeUndefined() +}, 30000)