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
20 changes: 20 additions & 0 deletions packages/opencode/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } fro
import { registerAdapter } from "@/control-plane/adapters"
import type { WorkspaceAdapter } from "@/control-plane/types"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Skill } from "../skill"

const log = Log.create({ service: "plugin" })

Expand Down Expand Up @@ -115,6 +116,7 @@ export const layer = Layer.effect(
const bus = yield* Bus.Service
const config = yield* Config.Service
const flags = yield* RuntimeFlags.Service
const skill = yield* Skill.Service

const state = yield* InstanceState.make<State>(
Effect.fn("Plugin.state")(function* (ctx) {
Expand Down Expand Up @@ -149,6 +151,23 @@ export const layer = Layer.effect(
},
// @ts-expect-error
$: typeof Bun === "undefined" ? undefined : Bun.$,
skills: {
all: () =>
bridge.promise(
skill.all().pipe(
Effect.orDie,
Effect.map((items) => items.map((item) => ({ ...item, description: item.description ?? "" }))),
),
),
get: (name: string) =>
bridge.promise(
skill.get(name).pipe(
Effect.orDie,
Effect.map((item) => (item ? { ...item, description: item.description ?? "" } : item)),
),
),
dirs: () => bridge.promise(skill.dirs().pipe(Effect.orDie)),
},
}

for (const plugin of flags.disableDefaultPlugins ? [] : INTERNAL_PLUGINS) {
Expand Down Expand Up @@ -291,6 +310,7 @@ export const layer = Layer.effect(
export const defaultLayer = layer.pipe(
Layer.provide(Bus.layer),
Layer.provide(Config.defaultLayer),
Layer.provide(Skill.defaultLayer),
Layer.provide(RuntimeFlags.defaultLayer),
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const configLayer = Config.layer.pipe(
const pluginLayer = Plugin.layer.pipe(
Layer.provide(Bus.layer),
Layer.provide(configLayer),
Layer.provide(SkillTest.empty),
Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })),
)
const agentLayer = Agent.layer.pipe(
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/test/plugin/auth-override.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Plugin } from "@/plugin"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { Auth } from "@/auth"
import { Bus } from "@/bus"
import { Skill } from "@/skill"
import { TestConfig } from "../fixture/config"
import { testEffect } from "../lib/effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
Expand All @@ -23,6 +24,7 @@ function layer(directory: string, plugins: string[]) {
Plugin.layer.pipe(
Layer.provide(Bus.layer),
Layer.provide(RuntimeFlags.layer()),
Layer.provide(Skill.defaultLayer),
Layer.provide(
TestConfig.layer({
get: () =>
Expand Down
5 changes: 5 additions & 0 deletions packages/opencode/test/plugin/cloudflare.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ const pluginInput = {
},
serverUrl: new URL("https://example.com"),
$: {} as never,
skills: {
all: async () => [],
get: async () => undefined,
dirs: async () => [],
},
}

function makeHookInput(overrides: { providerID?: string; apiId?: string; reasoning?: boolean }) {
Expand Down
5 changes: 5 additions & 0 deletions packages/opencode/test/plugin/codex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,11 @@ describe("plugin.codex", () => {
},
serverUrl: new URL("https://example.com"),
$: {} as never,
skills: {
all: async () => [],
get: async () => undefined,
dirs: async () => [],
},
},
{
issuer: server.url.origin,
Expand Down
5 changes: 5 additions & 0 deletions packages/opencode/test/plugin/github-copilot-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,11 @@ test("remaps fallback oauth model urls to the enterprise host", async () => {
},
serverUrl: new URL("https://example.com"),
$: {} as never,
skills: {
all: async () => [],
get: async () => undefined,
dirs: async () => [],
},
})

const models = await hooks.provider!.models!(
Expand Down
22 changes: 21 additions & 1 deletion packages/opencode/test/plugin/loader-shared.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { afterEach, describe, expect, spyOn } from "bun:test"
import { afterAll, afterEach, describe, expect, spyOn } from "bun:test"
import { Effect, Layer } from "effect"
import fs from "fs/promises"
import path from "path"
Expand All @@ -8,14 +8,33 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"

const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
const configContent = process.env.OPENCODE_CONFIG_CONTENT
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
delete process.env.OPENCODE_CONFIG_CONTENT

const { Plugin } = await import("../../src/plugin/index")
const { PluginLoader } = await import("../../src/plugin/loader")
const { readPackageThemes } = await import("../../src/plugin/shared")
const { Bus } = await import("../../src/bus")
const { Skill } = await import("../../src/skill")
const { Npm } = await import("@opencode-ai/core/npm")
const { TestConfig } = await import("../fixture/config")
const { RuntimeFlags } = await import("../../src/effect/runtime-flags")

afterAll(() => {
if (disableDefault === undefined) {
delete process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
} else {
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = disableDefault
}
if (configContent === undefined) {
delete process.env.OPENCODE_CONFIG_CONTENT
return
}
process.env.OPENCODE_CONFIG_CONTENT = configContent
})

afterEach(async () => {
await disposeAllInstances()
})
Expand Down Expand Up @@ -48,6 +67,7 @@ function load(dir: string, flags?: Parameters<typeof RuntimeFlags.layer>[0]) {
Plugin.layer.pipe(
Layer.provide(Bus.layer),
Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true, ...flags })),
Layer.provide(Skill.defaultLayer),
Layer.provide(
TestConfig.layer({
get: () =>
Expand Down
109 changes: 109 additions & 0 deletions packages/opencode/test/plugin/native-skills.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { afterEach, expect, test } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { disposeAllInstances, provideInstance, tmpdir } from "../fixture/fixture"

const disable = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
const cfg = process.env.OPENCODE_CONFIG_DIR
const content = process.env.OPENCODE_CONFIG_CONTENT
const home = process.env.OPENCODE_TEST_HOME
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"

const { Effect } = await import("effect")
const { MessageID, SessionID } = await import("../../src/session/schema")
const { ToolRegistry } = await import("../../src/tool/registry")

afterEach(async () => {
if (disable === undefined) delete process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
else process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = disable
if (cfg === undefined) delete process.env.OPENCODE_CONFIG_DIR
else process.env.OPENCODE_CONFIG_DIR = cfg
if (content === undefined) delete process.env.OPENCODE_CONFIG_CONTENT
else process.env.OPENCODE_CONFIG_CONTENT = content
if (home === undefined) delete process.env.OPENCODE_TEST_HOME
else process.env.OPENCODE_TEST_HOME = home
await disposeAllInstances()
})

// Validates the OMO+superpowers coexistence pattern:
// A plugin captures ctx.skills during server(), then calls ctx.skills.all()
// LAZILY (via a tool execute) after plugin loading completes.
// Native skills must be visible in the lazy call.
test("plugin tool can lazily call ctx.skills.all() and see native skills", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
// Create a native skill in the standard location
const skill = path.join(dir, ".opencode", "skill", "test-native")
await fs.mkdir(skill, { recursive: true })
await Bun.write(
path.join(skill, "SKILL.md"),
["---", "name: test-native", "description: A test native skill", "---", "", "# Test Native Skill", ""].join("\n"),
)

// Create a plugin that captures ctx.skills and exposes a tool
// whose execute calls ctx.skills.all() lazily
const plugin = path.join(dir, ".opencode", "plugin")
await fs.mkdir(plugin, { recursive: true })
const out = JSON.stringify(path.join(dir, "skills-result.json"))
await Bun.write(
path.join(plugin, "skill-reader.ts"),
[
'import { writeFileSync } from "fs"',
"",
"export default {",
' id: \"test.skill-reader\",',
" server: async (ctx) => {",
" // Capture ctx.skills — do NOT call all() during server()",
" const skills = ctx.skills",
" return {",
" tool: {",
" check_native_skills: {",
' description: \"Returns native skills found via ctx.skills.all()\",',
" args: {},",
" execute: async () => {",
" // LAZY call — runs after server() has returned",
" const all = await skills.all()",
` writeFileSync(${out}, JSON.stringify(all.map(s => s.name)))`,
' return all.map(s => s.name).join(\", \")',
" }",
" }",
" }",
" }",
" },",
"}",
"",
].join("\n"),
)
},
})

process.env.OPENCODE_TEST_HOME = tmp.path
process.env.OPENCODE_CONFIG_DIR = path.join(tmp.path, ".config")
delete process.env.OPENCODE_CONFIG_CONTENT
await fs.mkdir(process.env.OPENCODE_CONFIG_DIR, { recursive: true })

await Effect.runPromise(
Effect.gen(function* () {
const svc = yield* ToolRegistry.Service
const tools = yield* svc.all()
const tool = tools.find((t) => t.id === "check_native_skills")
if (!tool) throw new Error("Plugin tool not found")
yield* tool.execute(
{},
{
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make("msg_test"),
agent: "test",
abort: new AbortController().signal,
extra: {},
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)
}).pipe(Effect.provide(ToolRegistry.defaultLayer), provideInstance(tmp.path)),
)

const names = (await Bun.file(path.join(tmp.path, "skills-result.json")).json()) as string[]
expect(names).toContain("test-native")
}, 30000)
15 changes: 14 additions & 1 deletion packages/opencode/test/plugin/trigger.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { afterAll, describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
Expand All @@ -17,6 +17,10 @@ import { testEffect } from "../lib/effect"
import { AccountTest } from "../fake/account"
import { AuthTest } from "../fake/auth"
import { NpmTest } from "../fake/npm"
import { Skill } from "../../src/skill"

const configContent = process.env.OPENCODE_CONFIG_CONTENT
delete process.env.OPENCODE_CONFIG_CONTENT

const configLayer = Config.layer.pipe(
Layer.provide(EffectFlock.defaultLayer),
Expand All @@ -32,13 +36,22 @@ const it = testEffect(
Plugin.layer.pipe(
Layer.provide(Bus.layer),
Layer.provide(configLayer),
Layer.provide(Skill.defaultLayer),
Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })),
),
CrossSpawnSpawner.defaultLayer,
),
)
const systemHook = "experimental.chat.system.transform"

afterAll(() => {
if (configContent === undefined) {
delete process.env.OPENCODE_CONFIG_CONTENT
return
}
process.env.OPENCODE_CONFIG_CONTENT = configContent
})

function withProject<A, E, R>(source: string, self: Effect.Effect<A, E, R>) {
return provideTmpdirInstance((dir) =>
Effect.gen(function* () {
Expand Down
15 changes: 14 additions & 1 deletion packages/opencode/test/plugin/workspace-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { afterEach, describe, expect } from "bun:test"
import { afterAll, afterEach, describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
Expand Down Expand Up @@ -26,6 +26,10 @@ import { testEffect } from "../lib/effect"
import { AccountTest } from "../fake/account"
import { AuthTest } from "../fake/auth"
import { NpmTest } from "../fake/npm"
import { Skill } from "../../src/skill"

const configContent = process.env.OPENCODE_CONFIG_CONTENT
delete process.env.OPENCODE_CONFIG_CONTENT

const configLayer = Config.layer.pipe(
Layer.provide(EffectFlock.defaultLayer),
Expand All @@ -39,6 +43,7 @@ const configLayer = Config.layer.pipe(
const pluginLayer = Plugin.layer.pipe(
Layer.provide(Bus.layer),
Layer.provide(configLayer),
Layer.provide(Skill.defaultLayer),
Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })),
)
const noopBootstrapLayer = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
Expand All @@ -60,6 +65,14 @@ afterEach(async () => {
await disposeAllInstances()
})

afterAll(() => {
if (configContent === undefined) {
delete process.env.OPENCODE_CONFIG_CONTENT
return
}
process.env.OPENCODE_CONFIG_CONTENT = configContent
})

describe("plugin.workspace", () => {
it.live("plugin can install a workspace adapter", () =>
provideTmpdirInstance((dir) =>
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/test/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ process.env["OPENCODE_TEST_HOME"] = testHome
// Set test managed config directory to isolate tests from system managed settings
const testManagedConfigDir = path.join(dir, "managed")
process.env["OPENCODE_TEST_MANAGED_CONFIG_DIR"] = testManagedConfigDir
delete process.env["OPENCODE_CONFIG_CONTENT"]

// Write the cache version file to prevent global/index.ts from clearing the cache
const cacheDir = path.join(dir, "cache", "opencode")
Expand Down
Loading
Loading