From fa5ca85e30eec53cccefb165cfe1461a893435eb Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 11 Aug 2026 13:06:58 -0400 Subject: [PATCH] refactor(core): reuse plugin source discovery (#41794) --- packages/core/src/plugin/internal.ts | 4 ++ packages/core/src/plugin/skill.ts | 36 +++--------------- packages/core/test/plugin/skill.test.ts | 50 +++++++++++++++++-------- 3 files changed, 43 insertions(+), 47 deletions(-) diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 11547cdfce..1b47c04a2c 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -17,6 +17,7 @@ import { ConfigProviderPlugin } from "../config/plugin/provider" import { ConfigPolicyPlugin } from "../config/plugin/policy" import { ConfigReferencePlugin } from "../config/plugin/reference" import { ConfigSkillPlugin } from "../config/plugin/skill" +import { ConfigPluginSource } from "../config/plugin/source" import { ConfigWebSearchPlugin } from "../config/plugin/websearch" import { Bus } from "../bus" import { Environment } from "../environment" @@ -76,6 +77,7 @@ const services = Effect.fn("PluginInternal.services")(function* () { const command = yield* Command.Service const config = yield* Config.Service const credential = yield* Credential.Service + const pluginSources = yield* ConfigPluginSource.Service const bus = yield* Bus.Service const environment = yield* Environment.Service const mutation = yield* FileMutation.Service @@ -112,6 +114,7 @@ const services = Effect.fn("PluginInternal.services")(function* () { Context.make(Command.Service, command), Context.make(Config.Service, config), Context.make(Credential.Service, credential), + Context.make(ConfigPluginSource.Service, pluginSources), Context.make(Bus.Service, bus), Context.make(Environment.Service, environment), Context.make(FileMutation.Service, mutation), @@ -155,6 +158,7 @@ export const requirements = LayerNode.group([ Command.node, Config.node, Credential.node, + ConfigPluginSource.node, Bus.node, Environment.node, FileMutation.node, diff --git a/packages/core/src/plugin/skill.ts b/packages/core/src/plugin/skill.ts index 04b6c31612..6e90f6a626 100644 --- a/packages/core/src/plugin/skill.ts +++ b/packages/core/src/plugin/skill.ts @@ -6,12 +6,8 @@ import { define, type Context } from "@opencode-ai/plugin/effect/plugin" import { Effect } from "effect" import { AbsolutePath } from "../schema" import { Skill } from "../skill" -import { Config } from "../config" -import { Location } from "../location" -import { FSUtil } from "@opencode-ai/util/fs-util" +import { ConfigPluginSource } from "../config/plugin/source" import os from "os" -import path from "path" -import { fileURLToPath } from "url" import opencodeContent from "./skill/opencode.md" with { type: "text" } import reportContent from "./skill/report.md" with { type: "text" } @@ -72,32 +68,10 @@ const reportContentWithDiagnostics = Effect.fn("SkillPlugin.reportContentWithDia }) const configuredPlugins = Effect.fn("SkillPlugin.configuredPlugins")(function* () { - const config = yield* Config.Service - const fs = yield* FSUtil.Service - const location = yield* Location.Service - return yield* Effect.forEach(yield* config.entries(), (entry) => { - if (entry.type === "document") { - const directory = entry.path ? path.dirname(entry.path) : location.directory - return Effect.succeed( - (entry.info.plugins ?? []).map((item) => { - const ref = typeof item === "string" ? { package: item } : item - if (ref.package.startsWith("file://")) return fileURLToPath(ref.package) - if (ref.package.startsWith("./") || ref.package.startsWith("../")) return path.resolve(directory, ref.package) - return ref.package - }), - ) - } - if (entry.type !== "directory") return Effect.succeed([]) - return fs - .scan("{plugin,plugins}/*.{ts,js}", { - cwd: entry.path, - absolute: true, - include: "file", - dot: true, - symlink: true, - }) - .pipe(Effect.orElseSucceed(() => [])) - }).pipe(Effect.map((items) => items.flat().toSorted())) + const sources = yield* ConfigPluginSource.Service + return (yield* sources.operations()) + .map((operation) => (operation.type === "remove" ? `-${operation.target}` : operation.target)) + .toSorted() }) function terminal() { diff --git a/packages/core/test/plugin/skill.test.ts b/packages/core/test/plugin/skill.test.ts index e048177efd..8dedcc16de 100644 --- a/packages/core/test/plugin/skill.test.ts +++ b/packages/core/test/plugin/skill.test.ts @@ -1,18 +1,18 @@ import { describe, expect } from "bun:test" -import { NodeFileSystem } from "@effect/platform-node" -import { Config } from "@opencode-ai/core/config" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" -import { FSUtil } from "@opencode-ai/util/fs-util" -import { Location } from "@opencode-ai/core/location" -import { Effect, Stream } from "effect" +import { ConfigPluginSource } from "@opencode-ai/core/config/plugin/source" +import { Effect, Layer, Stream } from "effect" import { SkillPlugin } from "@opencode-ai/core/plugin/skill" -import { AbsolutePath } from "@opencode-ai/core/schema" import { Skill } from "@opencode-ai/core/skill" -import { location } from "../fixture/location" import { testEffect } from "../lib/effect" import { host } from "./host" const it = testEffect(AppNodeBuilder.build(Skill.node)) +const sources = (operations: readonly ConfigPluginSource.Operation[] = []) => + Layer.succeed( + ConfigPluginSource.Service, + ConfigPluginSource.Service.of({ operations: () => Effect.succeed(operations), changes: () => Stream.never }), + ) describe("SkillPlugin.Plugin", () => { it.effect("registers built-in skills", () => @@ -27,15 +27,7 @@ describe("SkillPlugin.Plugin", () => { reload: skill.reload, }, }), - ).pipe( - Effect.provide(Config.testLayer()), - Effect.provideService( - Location.Service, - Location.Service.of(location({ directory: AbsolutePath.make(import.meta.dir) })), - ), - Effect.provide(AppNodeBuilder.build(FSUtil.node)), - Effect.provide(NodeFileSystem.layer), - ) + ).pipe(Effect.provide(sources())) const skills = yield* skill.list() const report = skills.find((item) => item.id === "report") @@ -58,4 +50,30 @@ describe("SkillPlugin.Plugin", () => { expect(report?.content).toContain("- install/channel: beta") }), ) + + it.effect("reports canonical configured plugin sources with existing labels and ordering", () => + Effect.gen(function* () { + const skill = yield* Skill.Service + yield* SkillPlugin.Plugin.effect( + host({ + skill: { + list: () => Effect.die("unused skill.list"), + transform: skill.transform, + reload: skill.reload, + }, + }), + ) + const report = (yield* skill.list()).find((item) => item.id === "report") + expect(report?.content).toContain("- Active plugins: -disabled, local.ts, package-plugin, package-plugin") + }).pipe( + Effect.provide( + sources([ + { type: "add", target: "package-plugin", options: {} }, + { type: "remove", target: "disabled" }, + { type: "add", target: "local.ts", options: {}, mtime: 1 }, + { type: "add", target: "package-plugin", options: { enabled: true } }, + ]), + ), + ), + ) })