diff --git a/packages/core/src/config/plugin/formatter.ts b/packages/core/src/config/plugin/formatter.ts new file mode 100644 index 00000000000..e1b95d5e65f --- /dev/null +++ b/packages/core/src/config/plugin/formatter.ts @@ -0,0 +1,55 @@ +export * as ConfigFormatterPlugin from "./formatter.js" + +import { define } from "@opencode-ai/plugin/effect/plugin" +import type { Entry } from "@opencode-ai/schema/config" +import { Effect, Stream } from "effect" +import { Config } from "../../config.js" +import { Formatter } from "../../formatter.js" + +export const Plugin = define({ + id: "opencode.config.formatter", + effect: Effect.fn(function* (ctx) { + const config = yield* Config.Service + const formatter = yield* Formatter.Service + const loaded = { entries: [] as Entry[] } + yield* ctx.event + .subscribe() + .pipe( + Stream.filter((event) => event.type === "config.updated"), + Stream.runForEach(() => + config.entries().pipe( + Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))), + Effect.andThen(formatter.reload()), + ), + ), + Effect.forkScoped({ startImmediately: true }), + ) + loaded.entries = yield* config.entries() + yield* formatter.transform((draft) => { + const configured = Config.latest(loaded.entries, "formatter") + if (configured === false) { + draft.clear() + return + } + if (configured === undefined) { + for (const item of draft.list()) if (item.builtIn) draft.remove(item.name) + return + } + if (configured === true) return + for (const [name, entry] of Object.entries(configured)) { + if (entry.disabled) { + draft.remove(name) + continue + } + const current = draft.get(name) + draft.set(name, { + name, + extensions: entry.extensions ?? current?.extensions ?? [], + environment: { ...current?.environment, ...entry.environment }, + enabled: + current && !entry.command ? current.enabled : Effect.succeed(entry.command ? [...entry.command] : false), + }) + } + }) + }), +}) diff --git a/packages/core/src/config/plugin/image.ts b/packages/core/src/config/plugin/image.ts new file mode 100644 index 00000000000..c504e1ab923 --- /dev/null +++ b/packages/core/src/config/plugin/image.ts @@ -0,0 +1,42 @@ +export * as ConfigImagePlugin from "./image.js" + +import { define } from "@opencode-ai/plugin/effect/plugin" +import type { Entry } from "@opencode-ai/schema/config" +import { Effect, Stream } from "effect" +import { Config } from "../../config.js" +import { Image } from "../../image.js" + +export const Plugin = define({ + id: "opencode.config.image", + effect: Effect.fn(function* (ctx) { + const config = yield* Config.Service + const image = yield* Image.Service + const loaded = { entries: [] as Entry[] } + yield* ctx.event + .subscribe() + .pipe( + Stream.filter((event) => event.type === "config.updated"), + Stream.runForEach(() => + config.entries().pipe( + Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))), + Effect.andThen(image.reload()), + ), + ), + Effect.forkScoped({ startImmediately: true }), + ) + loaded.entries = yield* config.entries() + yield* image.transform((draft) => { + for (const entry of loaded.entries) { + if (entry.type !== "document" || !entry.info.media?.image) continue + draft.update((policy) => { + const configured = entry.info.media?.image + if (!configured) return + if (configured.auto_resize !== undefined) policy.autoResize = configured.auto_resize + if (configured.max_width !== undefined) policy.maxWidth = configured.max_width + if (configured.max_height !== undefined) policy.maxHeight = configured.max_height + if (configured.max_base64_bytes !== undefined) policy.maxBase64Bytes = configured.max_base64_bytes + }) + } + }) + }), +}) diff --git a/packages/core/src/config/plugin/tool-output.ts b/packages/core/src/config/plugin/tool-output.ts new file mode 100644 index 00000000000..d7a6d4a2142 --- /dev/null +++ b/packages/core/src/config/plugin/tool-output.ts @@ -0,0 +1,37 @@ +export * as ConfigToolOutputPlugin from "./tool-output.js" + +import { define } from "@opencode-ai/plugin/effect/plugin" +import type { Entry } from "@opencode-ai/schema/config" +import { Effect, Stream } from "effect" +import { Config } from "../../config.js" +import { ToolOutput } from "../../tool-output.js" + +export const Plugin = define({ + id: "opencode.config.tool-output", + effect: Effect.fn(function* (ctx) { + const config = yield* Config.Service + const output = yield* ToolOutput.Service + const loaded = { entries: [] as Entry[] } + yield* ctx.event + .subscribe() + .pipe( + Stream.filter((event) => event.type === "config.updated"), + Stream.runForEach(() => + config.entries().pipe( + Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))), + Effect.andThen(output.reload()), + ), + ), + Effect.forkScoped({ startImmediately: true }), + ) + loaded.entries = yield* config.entries() + yield* output.transform((draft) => { + const configured = Config.latest(loaded.entries, "tool_output") + if (!configured) return + draft.update((policy) => { + if (configured.max_lines !== undefined) policy.maxLines = configured.max_lines + if (configured.max_bytes !== undefined) policy.maxBytes = configured.max_bytes + }) + }) + }), +}) diff --git a/packages/core/src/formatter.ts b/packages/core/src/formatter.ts index 371bf11e231..55a7807a926 100644 --- a/packages/core/src/formatter.ts +++ b/packages/core/src/formatter.ts @@ -8,11 +8,23 @@ import { FSUtil } from "@opencode-ai/util/fs-util" import { Npm } from "@opencode-ai/util/npm" import { AppProcess } from "@opencode-ai/util/process" import { Global } from "@opencode-ai/util/global" -import { Config } from "./config.js" import { Location } from "./location.js" import { make, type Info } from "./formatter/builtins.js" +import { State } from "./state.js" -export interface Interface { +type Data = { + readonly formatters: Map +} + +export interface Draft { + readonly list: () => readonly Info[] + readonly get: (name: string) => Info | undefined + readonly set: (name: string, formatter: Info) => void + readonly remove: (name: string) => void + readonly clear: () => void +} + +export interface Interface extends State.Transformable { readonly file: (filepath: string) => Effect.Effect } @@ -21,66 +33,48 @@ export class Service extends Context.Service()("@opencode/v2 const layer = Layer.effect( Service, Effect.gen(function* () { - const config = yield* Config.Service const fs = yield* FSUtil.Service const location = yield* Location.Service const npm = yield* Npm.Service const processes = yield* AppProcess.Service const global = yield* Global.Service - const commands = new Map() - let formatters: Info[] = [] - - const load = yield* Effect.cached( - Effect.gen(function* () { - const configured = Config.latest(yield* config.entries(), "formatter") - if (!configured) { - yield* Effect.logInfo("all formatters are disabled") - return - } - - const builtIns = make({ - directory: location.directory, - worktree: location.project.directory, - fs, - npm, - processes, - bin: global.bin, - }) - formatters = builtIns - if (configured === true) return - - for (const [name, entry] of Object.entries(configured)) { - const index = formatters.findIndex((formatter) => formatter.name === name) - if (entry.disabled) { - if (index !== -1) formatters.splice(index, 1) - continue - } - - const builtIn = builtIns.find((formatter) => formatter.name === name) - const formatter: Info = { - name, - extensions: entry.extensions ?? builtIn?.extensions ?? [], - environment: { ...builtIn?.environment, ...entry.environment }, - enabled: - builtIn && !entry.command ? builtIn.enabled : Effect.succeed(entry.command ? [...entry.command] : false), - } - if (index === -1) formatters.push(formatter) - else formatters[index] = formatter - } - }).pipe(Effect.withSpan("Formatter.load")), - ) + const commands = new WeakMap() + const builtIns = make({ + directory: location.directory, + worktree: location.project.directory, + fs, + npm, + processes, + bin: global.bin, + }) + const state = State.create({ + name: "formatter", + initial: () => ({ + formatters: new Map(builtIns.map((formatter) => [formatter.name, { ...formatter, builtIn: true }])), + }), + draft: (data) => ({ + list: () => Array.from(data.formatters.values()), + get: (name) => data.formatters.get(name), + set: (name, formatter) => data.formatters.set(name, { ...formatter, name }), + remove: (name) => { + data.formatters.delete(name) + }, + clear: () => data.formatters.clear(), + }), + }) const command = Effect.fnUntraced(function* (formatter: Info) { - const cached = commands.get(formatter.name) + const cached = commands.get(formatter) if (cached !== undefined) return cached const result = yield* formatter.enabled - if (result !== false) commands.set(formatter.name, result) + if (result !== false) commands.set(formatter, result) return result }) const file = Effect.fn("Formatter.file")(function* (filepath: string) { - yield* load - const matching = formatters.filter((formatter) => formatter.extensions.includes(path.extname(filepath))) + const matching = Array.from(state.get().formatters.values()).filter((formatter) => + formatter.extensions.includes(path.extname(filepath)), + ) for (const formatter of matching) { const enabled = yield* command(formatter) @@ -118,12 +112,12 @@ const layer = Layer.effect( return false }) - return Service.of({ file }) + return Service.of({ file, transform: state.transform, reload: state.reload }) }), ) export const node = makeLocationNode({ service: Service, layer, - deps: [Config.node, FSUtil.node, Location.node, Npm.node, AppProcess.node, Global.node], + deps: [FSUtil.node, Location.node, Npm.node, AppProcess.node, Global.node], }) diff --git a/packages/core/src/formatter/builtins.ts b/packages/core/src/formatter/builtins.ts index 5dca6bcc5a6..ce753c3c91e 100644 --- a/packages/core/src/formatter/builtins.ts +++ b/packages/core/src/formatter/builtins.ts @@ -7,6 +7,7 @@ import { which } from "../util/which.js" export interface Info { readonly name: string + readonly builtIn?: boolean readonly environment?: Record readonly extensions: readonly string[] readonly enabled: Effect.Effect diff --git a/packages/core/src/image.ts b/packages/core/src/image.ts index 2325dcb4172..492ccf65d2d 100644 --- a/packages/core/src/image.ts +++ b/packages/core/src/image.ts @@ -2,8 +2,9 @@ export * as Image from "./image.js" import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { Context, Effect, Layer, Schema } from "effect" -import { Config } from "./config.js" import { FileSystem } from "./filesystem.js" +import type { DeepMutable } from "./schema.js" +import { State } from "./state.js" export class ResizerUnavailableError extends Schema.TaggedError()( "Image.ResizerUnavailableError", @@ -32,7 +33,18 @@ export class SizeError extends Schema.TaggedError()("Image.SizeError" } } -export interface Interface { +export interface Policy { + readonly autoResize: boolean + readonly maxWidth: number + readonly maxHeight: number + readonly maxBase64Bytes: number +} + +export interface Draft { + readonly update: (update: (policy: DeepMutable) => void) => void +} + +export interface Interface extends State.Transformable { readonly normalize: ( resource: string, content: FileSystem.Content & { readonly encoding: "base64" }, @@ -47,7 +59,18 @@ export class Service extends Context.Service()("@opencode/Im const layer = Layer.effect( Service, Effect.gen(function* () { - const config = yield* Config.Service + const state = State.create, Draft>({ + name: "image", + initial: () => ({ + autoResize: true, + maxWidth: 2_000, + maxHeight: 2_000, + maxBase64Bytes: 5 * 1024 * 1024, + }), + draft: (policy) => ({ + update: (update) => update(policy), + }), + }) const loadAdapter = yield* Effect.cached( Effect.tryPromise({ try: () => import("./image/photon.js"), @@ -58,22 +81,17 @@ const layer = Layer.effect( resource: string, content: FileSystem.Content & { readonly encoding: "base64" }, ) { - const image = Object.assign( - {}, - ...(yield* config.entries()).flatMap((entry) => - entry.type === "document" && entry.info.media?.image ? [entry.info.media.image] : [], - ), - ) + const policy = state.get() const normalize = yield* loadAdapter return yield* normalize(resource, content, { - autoResize: image.auto_resize ?? true, - maxWidth: image.max_width ?? 2_000, - maxHeight: image.max_height ?? 2_000, - maxBase64Bytes: image.max_base64_bytes ?? 5 * 1024 * 1024, + autoResize: policy.autoResize, + maxWidth: policy.maxWidth, + maxHeight: policy.maxHeight, + maxBase64Bytes: policy.maxBase64Bytes, }) }) - return Service.of({ normalize }) + return Service.of({ normalize, transform: state.transform, reload: state.reload }) }), ) -export const node = makeLocationNode({ service: Service, layer, deps: [Config.node] }) +export const node = makeLocationNode({ service: Service, layer, deps: [] }) diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index eaf250008a3..98b5335356d 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -9,6 +9,7 @@ import { Agent } from "./agent.js" import { AISDK } from "./aisdk.js" import { Catalog } from "./catalog.js" import { Command } from "./command.js" +import { Formatter } from "./formatter.js" import { Bus } from "./bus.js" import { Integration } from "./integration.js" import { MCP } from "./mcp/index.js" @@ -154,6 +155,7 @@ export const node = makeLocationNode({ AISDK.node, Catalog.node, Command.node, + Formatter.node, Integration.node, MCP.node, Location.node, diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 288a7d2909f..3f0f27524a0 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -12,6 +12,7 @@ import { AISDK } from "../aisdk.js" import { Catalog } from "../catalog.js" import { Command } from "../command.js" import { Credential } from "../credential.js" +import { Formatter } from "../formatter.js" import { Bus } from "../bus.js" import { Integration } from "../integration.js" import { Location } from "../location.js" @@ -36,6 +37,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p const commands = yield* Command.Service const bus = yield* Bus.Service const integration = yield* Integration.Service + const formatter = yield* Formatter.Service const mcp = yield* MCP.Service const location = yield* Location.Service const reference = yield* Reference.Service @@ -185,6 +187,22 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p event: { subscribe: () => bus.subscribe().pipe(Stream.filter(EventManifest.isServer)), }, + formatter: { + reload: formatter.reload, + transform: (callback) => + formatter.transform((draft) => { + callback({ + add: (definition) => + draft.set(definition.name, { + name: definition.name, + extensions: [...definition.extensions], + environment: definition.environment === undefined ? undefined : { ...definition.environment }, + enabled: Effect.succeed([...definition.command]), + }), + remove: draft.remove, + }) + }), + }, integration: { list: () => response(integration.list()), get: (input) => response(integration.get(Integration.ID.make(input.integrationID))), diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index 17607027a9b..5b0244ea9d3 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -12,6 +12,8 @@ import { Config } from "../config.js" import { Credential } from "../credential.js" import { ConfigAgentPlugin } from "../config/plugin/agent.js" import { ConfigCommandPlugin } from "../config/plugin/command.js" +import { ConfigFormatterPlugin } from "../config/plugin/formatter.js" +import { ConfigImagePlugin } from "../config/plugin/image.js" import { ConfigInstructionPlugin } from "../config/plugin/instruction.js" import { ConfigMCPPlugin } from "../config/plugin/mcp.js" import { ConfigProviderPlugin } from "../config/plugin/provider.js" @@ -20,6 +22,7 @@ import { ConfigReferencePlugin } from "../config/plugin/reference.js" import { ConfigSkillPlugin } from "../config/plugin/skill.js" import { ConfigPluginSource } from "../config/plugin/source.js" import { ConfigWebSearchPlugin } from "../config/plugin/websearch.js" +import { ConfigToolOutputPlugin } from "../config/plugin/tool-output.js" import { Bus } from "../bus.js" import { Environment } from "../environment/index.js" import { FileMutation } from "../file-mutation.js" @@ -57,6 +60,7 @@ import { ShellTool } from "../tool/plugin/shell.js" import { SkillTool } from "../tool/plugin/skill.js" import { SubagentTool } from "../tool/plugin/subagent.js" import { Tool } from "../tool.js" +import { ToolOutput } from "../tool-output.js" import { WebFetchTool } from "../tool/plugin/webfetch.js" import { WebSearchTool } from "../tool/plugin/websearch.js" import { WellKnown } from "../wellknown.js" @@ -111,6 +115,7 @@ const services = Effect.fn("PluginInternal.services")(function* () { const skill = yield* Skill.Service const skillDiscovery = yield* SkillDiscovery.Service const tools = yield* Tool.Service + const toolOutput = yield* ToolOutput.Service const watcher = yield* Watcher.Service const wellknown = yield* WellKnown.Service return Context.mergeAll( @@ -149,6 +154,7 @@ const services = Effect.fn("PluginInternal.services")(function* () { Context.make(Skill.Service, skill), Context.make(SkillDiscovery.Service, skillDiscovery), Context.make(Tool.Service, tools), + Context.make(ToolOutput.Service, toolOutput), Context.make(Watcher.Service, watcher), Context.make(WellKnown.Service, wellknown), ) @@ -194,6 +200,7 @@ export const requirements = LayerNode.group([ Skill.node, SkillDiscovery.node, Tool.node, + ToolOutput.node, Watcher.node, WellKnown.node, ]) @@ -232,6 +239,9 @@ const post = [ ConfigReferencePlugin.Plugin, ConfigAgentPlugin.Plugin, ConfigCommandPlugin.Plugin, + ConfigFormatterPlugin.Plugin, + ConfigImagePlugin.Plugin, + ConfigToolOutputPlugin.Plugin, ConfigSkillPlugin.Plugin, ConfigProviderPlugin.Plugin, ConfigWebSearchPlugin.Plugin, diff --git a/packages/core/src/tool-output.ts b/packages/core/src/tool-output.ts index 976ce1ff5fd..13920bfb29d 100644 --- a/packages/core/src/tool-output.ts +++ b/packages/core/src/tool-output.ts @@ -6,8 +6,9 @@ import { Context, Duration, Effect, Layer, Option, Schedule } from "effect" import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node" import { FSUtil } from "@opencode-ai/util/fs-util" import { Global } from "@opencode-ai/util/global" -import { Config } from "./config.js" import { Identifier } from "./id/id.js" +import type { DeepMutable } from "./schema.js" +import { State } from "./state.js" export const MAX_LINES = 2_000 export const MAX_BYTES = 50 * 1024 // 50 KiB @@ -16,7 +17,16 @@ export const DIRECTORY = "tool-output" type Result = Tool.Result -export interface Interface { +export interface Policy { + readonly maxLines: number + readonly maxBytes: number +} + +export interface Draft { + readonly update: (update: (policy: DeepMutable) => void) => void +} + +export interface Interface extends State.Transformable { readonly truncate: (result: Result) => Effect.Effect readonly cleanup: () => Effect.Effect } @@ -46,19 +56,23 @@ const cleanup = Effect.fn("ToolOutput.cleanup")(function* (fs: FSUtil.Interface, const layer = Layer.effect( Service, Effect.gen(function* () { - const config = yield* Config.Service const fs = yield* FSUtil.Service const global = yield* Global.Service const directory = path.join(global.data, DIRECTORY) + const state = State.create, Draft>({ + name: "tool-output", + initial: () => ({ maxLines: MAX_LINES, maxBytes: MAX_BYTES }), + draft: (policy) => ({ + update: (update) => update(policy), + }), + }) const truncate = Effect.fn("ToolOutput.truncate")(function* (result: Result) { if (result.metadata?.truncated !== undefined) return result const content = typeof result.content === "string" ? [{ type: "text" as const, text: result.content }] : (result.content ?? []) const text = content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n") - const configured = Config.latest(yield* config.entries(), "tool_output") - const maxLines = configured?.max_lines ?? MAX_LINES - const maxBytes = configured?.max_bytes ?? MAX_BYTES + const { maxLines, maxBytes } = state.get() const lines = text.split("\n") if (text.endsWith("\n")) lines.pop() const totalBytes = Buffer.byteLength(text, "utf-8") @@ -113,7 +127,12 @@ const layer = Layer.effect( } }) - return Service.of({ truncate, cleanup: () => cleanup(fs, directory) }) + return Service.of({ + truncate, + cleanup: () => cleanup(fs, directory), + transform: state.transform, + reload: state.reload, + }) }), ) @@ -137,5 +156,5 @@ const cleanupNode = makeGlobalNode({ export const node = makeLocationNode({ service: Service, layer, - deps: [Config.node, FSUtil.node, Global.node, cleanupNode], + deps: [FSUtil.node, Global.node, cleanupNode], }) diff --git a/packages/core/test/config/image.test.ts b/packages/core/test/config/image.test.ts new file mode 100644 index 00000000000..c56d84e8e9e --- /dev/null +++ b/packages/core/test/config/image.test.ts @@ -0,0 +1,58 @@ +import { describe, expect } from "bun:test" +import { Config } from "@opencode-ai/core/config" +import { ConfigImagePlugin } from "@opencode-ai/core/config/plugin/image" +import { Image } from "@opencode-ai/core/image" +import { Document, Info } from "@opencode-ai/schema/config" +import { ConfigMedia } from "@opencode-ai/schema/config/media" +import { Effect, Layer } from "effect" +import { host } from "../plugin/host" +import { it } from "../lib/effect" + +describe("ConfigImagePlugin.Plugin", () => { + it.live("materializes image policy from config", () => + Effect.gen(function* () { + const policy = { + autoResize: true, + maxWidth: 2_000, + maxHeight: 2_000, + maxBase64Bytes: 5 * 1024 * 1024, + } + const image = Image.Service.of({ + normalize: () => Effect.die("unused image.normalize"), + reload: () => Effect.void, + transform: (callback) => + Effect.sync(() => { + callback({ update: (update) => update(policy) }) + return { dispose: Effect.void } + }), + }) + yield* ConfigImagePlugin.Plugin.effect(host()).pipe( + Effect.provideService(Image.Service, image), + Effect.provide( + Config.testLayer([ + new Document({ + type: "document", + info: new Info({ + media: new ConfigMedia.Info({ + image: new ConfigMedia.Image({ + auto_resize: false, + max_width: 1_000, + max_height: 800, + max_base64_bytes: 123_456, + }), + }), + }), + }), + ]), + ), + ) + + expect(policy).toEqual({ + autoResize: false, + maxWidth: 1_000, + maxHeight: 800, + maxBase64Bytes: 123_456, + }) + }), + ) +}) diff --git a/packages/core/test/formatter.test.ts b/packages/core/test/formatter.test.ts index 967a6df5120..d28e7471db8 100644 --- a/packages/core/test/formatter.test.ts +++ b/packages/core/test/formatter.test.ts @@ -7,33 +7,32 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import { Npm } from "@opencode-ai/util/npm" import { Document, Info } from "@opencode-ai/schema/config" import { Config } from "../src/config" +import { ConfigFormatterPlugin } from "../src/config/plugin/formatter" import { Formatter } from "../src/formatter" import { Location } from "../src/location" import { location } from "./fixture/location" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" +import { host } from "./plugin/host" const it = testEffect(Layer.empty) type ConfigInput = typeof Info.Encoded function formatterLayer(directory: string, configured?: ConfigInput["formatter"]) { - const entries = - configured === undefined - ? [] - : [ - new Document({ - type: "document", - info: Schema.decodeUnknownSync(Info)({ formatter: configured }), - }), - ] - return AppNodeBuilder.build(Formatter.node, [ - [Config.node, Config.testLayer(entries)], + const layer = AppNodeBuilder.build(Formatter.node, [ [ Location.node, Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))), ], [Npm.node, Layer.mock(Npm.Service, { which: () => Effect.succeed(undefined) })], ]) + const entries = + configured === undefined + ? [] + : [new Document({ type: "document", info: Schema.decodeUnknownSync(Info)({ formatter: configured }) })] + return Layer.effectDiscard(ConfigFormatterPlugin.Plugin.effect(host()).pipe(Effect.provide(Config.testLayer(entries)))).pipe( + Layer.provideMerge(layer), + ) } function withTemp(body: (directory: string) => Effect.Effect) { @@ -162,4 +161,34 @@ describe("Formatter", () => { ), ), ) + + it.live("resolves a replacement formatter command independently", () => + withTemp((directory) => + Effect.gen(function* () { + const formatter = yield* Formatter.Service + const file = path.join(directory, "test.replaced") + const register = (content: string) => + formatter.transform((draft) => { + draft.set("replacement", { + name: "replacement", + extensions: [".replaced"], + enabled: Effect.succeed([ + process.execPath, + "-e", + `require('fs').writeFileSync(process.argv.at(-1), '${content}')`, + "$FILE", + ]), + }) + }) + + yield* register("first") + expect(yield* formatter.file(file)).toBe(true) + expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("first") + + yield* register("second") + expect(yield* formatter.file(file)).toBe(true) + expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("second") + }).pipe(Effect.provide(formatterLayer(directory, false))), + ), + ) }) diff --git a/packages/core/test/lib/image.ts b/packages/core/test/lib/image.ts index 72ac03fdf1e..45d4137cf28 100644 --- a/packages/core/test/lib/image.ts +++ b/packages/core/test/lib/image.ts @@ -4,4 +4,6 @@ import { Effect, Layer } from "effect" /** Passthrough resizer for tests that build Tool.node without a Location. */ export const imagePassthrough = Layer.mock(Image.Service, { normalize: (_resource, content) => Effect.succeed(content), + transform: () => Effect.die("unused image.transform"), + reload: () => Effect.die("unused image.reload"), }) diff --git a/packages/core/test/plugin.test.ts b/packages/core/test/plugin.test.ts index 9277d44b98b..0a77be3c69f 100644 --- a/packages/core/test/plugin.test.ts +++ b/packages/core/test/plugin.test.ts @@ -8,6 +8,7 @@ import { Bus } from "@opencode-ai/core/bus" import { Plugin } from "@opencode-ai/core/plugin" import { PluginHost } from "@opencode-ai/core/plugin/host" import { PluginRuntime } from "@opencode-ai/core/plugin/runtime" +import { Formatter } from "@opencode-ai/core/formatter" import { Location } from "@opencode-ai/core/location" import { Project } from "@opencode-ai/core/project" import { AbsolutePath } from "@opencode-ai/core/schema" @@ -16,6 +17,8 @@ import { SessionMessage } from "@opencode-ai/core/session/message" import { Tool } from "@opencode-ai/core/tool" import { testEffect } from "./lib/effect" import { PluginTestLayer } from "./plugin/fixture" +import fs from "fs/promises" +import path from "path" const it = testEffect(PluginTestLayer) @@ -99,6 +102,34 @@ describe("Plugin", () => { }), ) + it.effect("registers formatters through the plugin context", () => + Effect.gen(function* () { + const plugins = yield* Plugin.Service + const formatter = yield* Formatter.Service + const location = yield* Location.Service + const host = yield* PluginHost.make(plugins) + const file = path.join(location.directory, "plugin.formatter-test") + yield* Effect.promise(() => fs.writeFile(file, "before")) + const registration = yield* host.formatter.transform((draft) => { + draft.add({ + name: "plugin", + command: [ + process.execPath, + "-e", + "const fs = require('fs'); fs.writeFileSync(process.argv.at(-1), 'after')", + "$FILE", + ], + extensions: [".formatter-test"], + }) + }) + + expect(yield* formatter.file(file)).toBe(true) + expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("after") + yield* registration.dispose + expect(yield* formatter.file(file)).toBe(false) + }), + ) + it.effect("replaces plugins by ID and version", () => Effect.gen(function* () { const plugins = yield* Plugin.Service diff --git a/packages/core/test/plugin/fixture.ts b/packages/core/test/plugin/fixture.ts index 0f1ba64004a..7ddafc5ecbf 100644 --- a/packages/core/test/plugin/fixture.ts +++ b/packages/core/test/plugin/fixture.ts @@ -10,6 +10,7 @@ import { Bus } from "@opencode-ai/core/bus" import { FileSystem } from "@opencode-ai/core/filesystem" import { FSUtil } from "@opencode-ai/util/fs-util" import { Form } from "@opencode-ai/core/form" +import { Formatter } from "@opencode-ai/core/formatter" import { Integration } from "@opencode-ai/core/integration" import { Location } from "@opencode-ai/core/location" import { MCP } from "@opencode-ai/core/mcp/index" @@ -44,6 +45,7 @@ export const PluginTestLayer = LayerNode.compile( Credential.node, Bus.node, Form.node, + Formatter.node, LayerNodePlatform.httpClient, Plugin.node, Agent.node, diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index f6d5920fd4d..e4bed73d925 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -48,6 +48,10 @@ export function host(overrides: Overrides = {}): Plugin.Context { event: overrides.event ?? { subscribe: () => Stream.empty, }, + formatter: overrides.formatter ?? { + transform: () => Effect.die("unused formatter.transform"), + reload: () => Effect.die("unused formatter.reload"), + }, integration: overrides.integration ?? { list: () => Effect.die("unused integration.list"), get: () => Effect.die("unused integration.get"), diff --git a/packages/core/test/tool-output.test.ts b/packages/core/test/tool-output.test.ts index b0bc3ba676b..fd296f46c22 100644 --- a/packages/core/test/tool-output.test.ts +++ b/packages/core/test/tool-output.test.ts @@ -1,7 +1,8 @@ import { describe, expect } from "bun:test" import path from "path" -import { Effect } from "effect" +import { Effect, Layer } from "effect" import { Config } from "@opencode-ai/core/config" +import { ConfigToolOutputPlugin } from "@opencode-ai/core/config/plugin/tool-output" import { Document, Info } from "@opencode-ai/schema/config" import { ConfigToolOutput } from "@opencode-ai/schema/config/tool-output" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -12,6 +13,7 @@ import { Global } from "@opencode-ai/util/global" import { Identifier } from "@opencode-ai/core/id/id" import { tmpdir } from "./fixture/tmpdir" import { it } from "./lib/effect" +import { host } from "./plugin/host" const withStore = ( body: (output: ToolOutput.Interface, fs: FSUtil.Interface, root: string) => Effect.Effect, @@ -21,10 +23,12 @@ const withStore = ( Effect.promise(() => tmpdir()), (tmp) => { const config = Config.testLayer([new Document({ type: "document", info })]) - const layer = AppNodeBuilder.build(LayerNode.group([ToolOutput.node, FSUtil.node]), [ - [Config.node, config], + const base = AppNodeBuilder.build(LayerNode.group([ToolOutput.node, FSUtil.node]), [ [Global.node, Global.layerWith({ data: tmp.path })], ]) + const layer = Layer.effectDiscard( + ConfigToolOutputPlugin.Plugin.effect(host()).pipe(Effect.provide(config)), + ).pipe(Layer.provideMerge(base)) return Effect.gen(function* () { return yield* body(yield* ToolOutput.Service, yield* FSUtil.Service, tmp.path) }).pipe(Effect.provide(layer)) diff --git a/packages/core/test/tool-read.test.ts b/packages/core/test/tool-read.test.ts index f00c1711e4e..3bdeb035e0b 100644 --- a/packages/core/test/tool-read.test.ts +++ b/packages/core/test/tool-read.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect } from "bun:test" import path from "path" import { Effect, Exit, Layer, Stream } from "effect" import { Config } from "@opencode-ai/core/config" +import { ConfigImagePlugin } from "@opencode-ai/core/config/plugin/image" import { Document, Info } from "@opencode-ai/schema/config" import { ConfigMedia } from "@opencode-ai/schema/config/media" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" @@ -25,6 +26,7 @@ import { Environment } from "@opencode-ai/core/environment/index" import { testEffect } from "./lib/effect" import { permissionLayer } from "./lib/permission" import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool" +import { host } from "./plugin/host" const readToolNode = makeLocationNode({ name: "test/read-tool-plugin", @@ -90,7 +92,8 @@ const permission = permissionLayer({ ), }) const config = Config.testLayer() -const imageLayer = AppNodeBuilder.build(Image.node, [[Config.node, config]]) +const imageLayer = AppNodeBuilder.build(Image.node) +const configureImage = ConfigImagePlugin.Plugin.effect(host()) const testFileSystem = Layer.effect( FSUtil.Service, FSUtil.Service.use((fs) => @@ -132,11 +135,15 @@ const mutation = Layer.succeed( ) const unavailableImage = Layer.succeed( Image.Service, - Image.Service.of({ normalize: () => Effect.fail(new Image.ResizerUnavailableError()) }), + Image.Service.of({ + normalize: () => Effect.fail(new Image.ResizerUnavailableError()), + transform: () => Effect.die("unused image.transform"), + reload: () => Effect.die("unused image.reload"), + }), ) const readLayer = (imageLayer: Layer.Layer) => Layer.mergeAll( - AppNodeBuilder.build(LayerNode.group([Tool.node, readToolNode]), [ + AppNodeBuilder.build(LayerNode.group([Tool.node, readToolNode, Image.node]), [ [ReadToolFileSystem.node, reader], [Permission.node, permission], [Config.node, config], @@ -395,6 +402,7 @@ describe("ReadTool", () => { }), }), ]) + yield* configureImage const registry = yield* Tool.Service expect( @@ -436,6 +444,7 @@ describe("ReadTool", () => { }), }), ]) + yield* configureImage const registry = yield* Tool.Service const result = yield* executeTool(registry, { sessionID, @@ -477,6 +486,7 @@ describe("ReadTool", () => { }), }), ]) + yield* configureImage const registry = yield* Tool.Service expect( diff --git a/packages/plugin/src/effect/formatter.ts b/packages/plugin/src/effect/formatter.ts new file mode 100644 index 00000000000..6b5e2de007e --- /dev/null +++ b/packages/plugin/src/effect/formatter.ts @@ -0,0 +1,19 @@ +import type { Effect } from "effect" +import type { Transform } from "./registration.js" + +export interface FormatterDefinition { + readonly name: string + readonly command: readonly string[] + readonly extensions: readonly string[] + readonly environment?: Readonly> +} + +export interface FormatterDraft { + readonly add: (formatter: FormatterDefinition) => void + readonly remove: (name: string) => void +} + +export interface FormatterDomain { + readonly transform: Transform + readonly reload: () => Effect.Effect +} diff --git a/packages/plugin/src/effect/plugin.ts b/packages/plugin/src/effect/plugin.ts index 78c630083d3..72f9f6de3b5 100644 --- a/packages/plugin/src/effect/plugin.ts +++ b/packages/plugin/src/effect/plugin.ts @@ -7,6 +7,7 @@ import type { AISDKDomain } from "./aisdk.js" import type { CatalogDomain } from "./catalog.js" import type { CommandDomain } from "./command.js" import type { EventDomain } from "./event.js" +import type { FormatterDomain } from "./formatter.js" import type { IntegrationDomain } from "./integration.js" import type { MCPDomain } from "./mcp.js" import type { ReferenceDomain } from "./reference.js" @@ -24,6 +25,7 @@ export interface Context { readonly catalog: CatalogDomain readonly command: CommandDomain readonly event: EventDomain + readonly formatter: FormatterDomain readonly integration: IntegrationDomain readonly mcp: MCPDomain readonly plugin: PluginApi diff --git a/packages/plugin/src/promise/adapter.ts b/packages/plugin/src/promise/adapter.ts index 32333056838..0211812ce0d 100644 --- a/packages/plugin/src/promise/adapter.ts +++ b/packages/plugin/src/promise/adapter.ts @@ -158,6 +158,10 @@ export function fromPromise(plugin: Plugin) { ), ), }, + formatter: { + transform: transform(host.formatter), + reload: () => run(host.formatter.reload()), + }, integration: { list: adaptApiMethod(IntegrationEndpoints["integration.list"], host.integration.list), get: adaptApiMethod(IntegrationEndpoints["integration.get"], host.integration.get), diff --git a/packages/plugin/src/promise/formatter.ts b/packages/plugin/src/promise/formatter.ts new file mode 100644 index 00000000000..93b091878ea --- /dev/null +++ b/packages/plugin/src/promise/formatter.ts @@ -0,0 +1,18 @@ +import type { Transform } from "./registration.js" + +export interface FormatterDefinition { + readonly name: string + readonly command: readonly string[] + readonly extensions: readonly string[] + readonly environment?: Readonly> +} + +export interface FormatterDraft { + readonly add: (formatter: FormatterDefinition) => void + readonly remove: (name: string) => void +} + +export interface FormatterDomain { + readonly transform: Transform + readonly reload: () => Promise +} diff --git a/packages/plugin/src/promise/plugin.ts b/packages/plugin/src/promise/plugin.ts index 9a05f1c4600..21e5e93373d 100644 --- a/packages/plugin/src/promise/plugin.ts +++ b/packages/plugin/src/promise/plugin.ts @@ -6,6 +6,7 @@ import type { AISDKDomain } from "./aisdk.js" import type { CatalogDomain } from "./catalog.js" import type { CommandDomain } from "./command.js" import type { EventDomain } from "./event.js" +import type { FormatterDomain } from "./formatter.js" import type { IntegrationDomain } from "./integration.js" import type { MCPDomain } from "./mcp.js" import type { ReferenceDomain } from "./reference.js" @@ -23,6 +24,7 @@ export interface Context { readonly catalog: CatalogDomain readonly command: CommandDomain readonly event: EventDomain + readonly formatter: FormatterDomain readonly integration: IntegrationDomain readonly mcp: MCPDomain readonly plugin: PluginApi