Skip to content
Merged
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributi
### General Principles

- Keep things in one function unless composable or reusable
- Validate unknown values once at the boundary that owns them. Pass typed values inward instead of repeating `typeof value === "object"` and property-existence checks. Do not defensively revalidate values already guaranteed by a schema, constructor, or internal type.
- Do not extract single-use helpers preemptively. Inline the logic at the call site unless the helper is reused, hides a genuinely complex boundary, or has a clear independent name that improves the caller.
- Before adding complexity for a speculative or vanishingly unlikely race or security edge case, explain the concrete failure mode, likelihood, and complexity cost to the user and get their buy-in. Do not silently expand scope for theoretical robustness.
- Avoid `try`/`catch` where possible
Expand Down
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions packages/app/e2e/regression/project-extensions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ test("project Extensions stays inside settings while plugins load", async ({ pag
data: (project ? ["shared-plugin", "project-plugin"] : ["shared-plugin"]).map((id) => ({
id,
source: { type: "package", package: id },
status: "active",
tui: false,
state: { status: "active" },
features: { server: true },
})),
},
})
Expand Down
7 changes: 6 additions & 1 deletion packages/app/e2e/regression/settings-loading.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,12 @@ test("extensions opens without waiting for MCPs or plugins", async ({ page }) =>
json: {
location: { directory },
data: [
{ id: "demo-plugin", source: { type: "package", package: "demo-plugin" }, status: "active", tui: false },
{
id: "demo-plugin",
source: { type: "package", package: "demo-plugin" },
state: { status: "active" },
features: { server: true },
},
],
},
})
Expand Down
18 changes: 14 additions & 4 deletions packages/app/src/providers/catalog/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,20 @@ import { pluginLabels } from "./plugin"
describe("pluginLabels", () => {
test("omits built-in plugins", () => {
const plugins: PluginInfo[] = [
{ id: "opencode.internal", source: { type: "builtin" }, status: "active", tui: false },
{ id: "package-plugin", source: { type: "package", package: "example" }, status: "active", tui: false },
{ id: "local-plugin", source: { type: "local", path: "/tmp/plugin.ts" }, status: "active", tui: false },
{ id: "sdk-plugin", source: { type: "sdk" }, status: "active", tui: false },
{ id: "opencode.internal", source: { type: "builtin" }, state: { status: "active" }, features: { server: true } },
{
id: "package-plugin",
source: { type: "package", package: "example" },
state: { status: "active" },
features: { server: true },
},
{
id: "local-plugin",
source: { type: "local", path: "/tmp/plugin.ts" },
state: { status: "active" },
features: { server: true },
},
{ id: "sdk-plugin", source: { type: "sdk" }, state: { status: "active" }, features: { server: true } },
]

expect(pluginLabels(plugins)).toEqual(["package-plugin", "local-plugin", "sdk-plugin"])
Expand Down
17 changes: 11 additions & 6 deletions packages/cli/src/commands/handlers/plugin/list.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { EOL } from "node:os"
import path from "node:path"
import { Effect } from "effect"
import { OpenCode, type PluginInfo } from "@opencode-ai/client"
import { Service } from "@opencode-ai/client/effect/service"
Expand All @@ -7,7 +8,7 @@ import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
import { Config } from "../../../config"
import { Global } from "@opencode-ai/util/global"
import { discoverTuiPlugins, tuiPluginDirectories } from "@opencode-ai/tui/plugin/discovery"
import { discoverTuiPlugins, localPluginDirectories } from "@opencode-ai/tui/plugin/discovery"

export default Runtime.handler(
Commands.commands.plugin.commands.list,
Expand All @@ -19,7 +20,7 @@ export default Runtime.handler(
const global = yield* Global.Service
const info = yield* config.get()
const discovered = yield* Effect.promise(() =>
tuiPluginDirectories(process.cwd(), global.config).then(discoverTuiPlugins),
localPluginDirectories(process.cwd(), global.config).then(discoverTuiPlugins),
)
const output = format(
response.data,
Expand Down Expand Up @@ -48,11 +49,15 @@ export function format(
const server = plugins
.filter((plugin) => builtin || plugin.source.type !== "builtin")
.toSorted((a, b) => name(a).localeCompare(name(b)))
.map((plugin) => `${name(plugin)} (${plugin.status})`)
.map((plugin) => `${name(plugin)} (${plugin.state.status})`)
const advertised = plugins.flatMap((plugin) =>
plugin.status === "active" && plugin.tui && plugin.source.type === "package"
? [{ target: plugin.source.package, source: "advertised" as const }]
: [],
plugin.state.status !== "active" || !plugin.features.tui
? []
: plugin.source.type === "package"
? [{ target: plugin.source.package, source: "advertised" as const }]
: plugin.source.type === "local"
? [{ target: path.dirname(plugin.source.path), source: "advertised" as const }]
: [],
)
const targets = [...tui, ...advertised]
.filter((plugin, index, all) => all.findIndex((candidate) => candidate.target === plugin.target) === index)
Expand Down
32 changes: 25 additions & 7 deletions packages/cli/test/plugin-list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,23 @@ test("formats server and TUI plugins in sections without builtins", () => {
expect(
format(
[
{ id: "opencode.agent", source: { type: "builtin" }, status: "active", tui: false },
{ id: "opencode.agent", source: { type: "builtin" }, state: { status: "active" }, features: { server: true } },
{
id: "acme.dual",
source: { type: "package", package: "acme-plugin@1.0.0" },
status: "active",
tui: true,
state: { status: "active" },
features: { server: true, tui: true },
},
{
source: { type: "package", package: "broken-plugin" },
status: "failed",
error: "broken",
tui: false,
state: { status: "failed", error: "broken" },
features: { server: true },
},
{
id: "local.dual",
source: { type: "local", path: "/tmp/local/index.ts" },
state: { status: "active" },
features: { server: true, tui: true },
},
],
[
Expand All @@ -28,19 +33,32 @@ test("formats server and TUI plugins in sections without builtins", () => {
).toBe(
[
"TUI",
"/tmp/local (advertised)",
"/tmp/local.ts (discovered)",
"acme-plugin@1.0.0 (advertised)",
"tui-only (configured)",
"",
"Server",
"acme.dual (active)",
"broken-plugin (failed)",
"local.dual (active)",
].join(EOL),
)
})

test("includes builtins when requested", () => {
expect(
format([{ id: "opencode.agent", source: { type: "builtin" }, status: "active", tui: false }], [], true),
format(
[
{
id: "opencode.agent",
source: { type: "builtin" },
state: { status: "active" },
features: { server: true },
},
],
[],
true,
),
).toBe(["Server", "opencode.agent (active)"].join(EOL))
})
3 changes: 2 additions & 1 deletion packages/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"effect": "catalog:",
"solid-js": "catalog:"
"solid-js": "catalog:",
"zod": "catalog:"
}
}
2 changes: 2 additions & 0 deletions packages/client/src/effect/api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { ModelApi, ProviderApi, WebsearchApi } from "./api/api.js"

export type { RpcApi, RpcClient } from "./rpc.js"

export type * from "./api/api.js"

export type WebSearchApi<E = never> = WebsearchApi<E>
Expand Down
14 changes: 14 additions & 0 deletions packages/client/src/effect/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1573,6 +1573,19 @@ export interface SkillApi<E = never> {
readonly list: SkillListOperation<E>
}

export type RpcCallInput = {
readonly rpcID: string
readonly method: string
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly input?: unknown | undefined
}
export type RpcCallOutput = { readonly output?: unknown }
export type RpcCallOperation<E = never> = (input: RpcCallInput) => Effect.Effect<RpcCallOutput, E>

export interface RpcApi<E = never> {
readonly call: RpcCallOperation<E>
}

export type EventSubscribeOutput = OpenCodeEvent
export type EventSubscribeOperation<E = never> = () => Stream.Stream<EventSubscribeOutput, E>

Expand Down Expand Up @@ -2073,6 +2086,7 @@ export interface AppApi<E = never> {
readonly file: FileApi<E>
readonly command: CommandApi<E>
readonly skill: SkillApi<E>
readonly rpc: RpcApi<E>
readonly event: EventApi<E>
readonly pty: PtyApi<E>
readonly experimental: ExperimentalApi<E>
Expand Down
58 changes: 58 additions & 0 deletions packages/client/src/effect/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
export * as OpenCode from "./client.js"

import { Cause, Context, Effect, Stream } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { SharedEvents } from "../shared-events.js"
import { ClientError, OpenCode } from "./generated/index.js"
import { RpcClientRuntime } from "./rpc.js"
import type { RpcCallOptions } from "../promise/rpc.js"

const CurrentHeaders = Context.Reference<RpcCallOptions["headers"]>("@opencode-ai/client/effect/rpc/headers", {
defaultValue: () => undefined,
})

export const make = Effect.fn("OpenCode.make")(function* (options?: { readonly baseUrl?: URL | string }) {
const httpClient = yield* HttpClient.HttpClient
const raw = yield* OpenCode.make(options).pipe(
Effect.provideService(
HttpClient.HttpClient,
HttpClient.mapRequestEffect(httpClient, (request) =>
Effect.map(CurrentHeaders, (headers) =>
headers ? HttpClientRequest.setHeaders(request, new Headers(headers)) : request,
),
),
),
)
const context = yield* Effect.context()
const native = raw.event.subscribe()
// Async iterators throw a squashed cause; retain the native typed failures and defects intact.
class EventFailure {
constructor(readonly cause: Cause.Cause<Stream.Error<typeof native>>) {}
}
const shared = SharedEvents.make((signal) =>
Stream.toAsyncIterableWith(
native.pipe(
Stream.interruptWhen(RpcClientRuntime.aborted(signal)),
Stream.catchCause((cause) => Stream.fail(new EventFailure(cause))),
),
context,
),
)
const subscribe = () =>
Stream.fromAsyncIterable(shared.subscribe(), (error) => error).pipe(
Stream.catch((error) =>
Stream.failCause(error instanceof EventFailure ? error.cause : Cause.fail(new ClientError({ cause: error }))),
),
)
return {
...raw,
event: { ...raw.event, subscribe },
rpc: Object.assign(
RpcClientRuntime.make(
(input, options) => raw.rpc.call(input).pipe(Effect.provideService(CurrentHeaders, options?.headers)),
subscribe,
),
raw.rpc,
),
}
})
14 changes: 14 additions & 0 deletions packages/client/src/effect/generated/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,8 @@ import type {
CommandListOutput,
SkillListInput,
SkillListOutput,
RpcCallInput,
RpcCallOutput,
EventSubscribeOutput,
PtyListInput,
PtyListOutput,
Expand Down Expand Up @@ -1166,6 +1168,17 @@ const EndpointSkillList = (raw: RawClient["server.skill"]) => (input?: SkillList

const adaptGroupSkill = (raw: RawClient["server.skill"]) => ({ list: EndpointSkillList(raw) })

const EndpointRpcCall = (raw: RawClient["server.rpc"]) => (input: RpcCallInput) =>
preserveEffect<RpcCallOutput>()(
raw["rpc.call"]({
params: { rpcID: input["rpcID"], method: input["method"] },
query: { location: input["location"] },
payload: { input: input["input"] },
}).pipe(Effect.mapError(mapClientError)),
)

const adaptGroupRpc = (raw: RawClient["server.rpc"]) => ({ call: EndpointRpcCall(raw) })

const EndpointEventSubscribe = (raw: RawClient["server.event"]) => () =>
preserveStream<EventSubscribeOutput>()(
Stream.unwrap(
Expand Down Expand Up @@ -1564,6 +1577,7 @@ const adaptClient = (raw: RawClient) => ({
file: adaptGroupFile(raw["server.fs"]),
command: adaptGroupCommand(raw["server.command"]),
skill: adaptGroupSkill(raw["server.skill"]),
rpc: adaptGroupRpc(raw["server.rpc"]),
event: adaptGroupEvent(raw["server.event"]),
pty: adaptGroupPty(raw["server.pty"]),
experimental: adaptGroupExperimental(raw["server.experimental"]),
Expand Down
6 changes: 5 additions & 1 deletion packages/client/src/effect/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
// TODO: Keep additional network capabilities inside Schema and Protocol as the client grows; /effect must never import
// Core or Server. Preserve these datatype exports so internal model reorganizations do not require caller migrations.
import type { Effect } from "effect"
import type { OpenCode } from "./client.js"

export * from "./generated/index"
export { OpenCode } from "./client.js"
export type {
AgentApi,
AppApi,
Expand All @@ -15,6 +17,8 @@ export type {
PluginApi,
ProviderApi,
ReferenceApi,
RpcApi,
RpcClient,
WebSearchApi,
SessionApi,
SkillApi,
Expand Down Expand Up @@ -48,4 +52,4 @@ export { Skill } from "@opencode-ai/schema/skill"
export { Prompt } from "@opencode-ai/schema/prompt"
export { PromptInput } from "@opencode-ai/schema/prompt-input"
export type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
export type OpenCodeClient = Effect.Success<ReturnType<typeof import("./generated/client").make>>
export type OpenCodeClient = Effect.Success<ReturnType<typeof OpenCode.make>>
Loading
Loading