feat(plugin): add vcs provider api (#44979)

This commit is contained in:
Shoubhit Dash 2026-08-25 19:37:06 +05:30 committed by GitHub
parent 7601ab9fc4
commit 9c1787617c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 605 additions and 39 deletions

View file

@ -13,6 +13,7 @@ export type ReferenceApi = Client["reference"]
export type WebSearchApi = Client["websearch"]
export type SessionApi = Client["session"]
export type SkillApi = Client["skill"]
export type VcsApi = Client["vcs"]
export interface CatalogApi {
readonly provider: ProviderApi

View file

@ -22,6 +22,7 @@ import { Reference } from "./reference.js"
import { Skill } from "./skill.js"
import { State } from "./state.js"
import { Tool } from "./tool.js"
import { Vcs } from "./vcs.js"
import { PluginHooks } from "./plugin/hooks.js"
export interface Interface {
@ -199,6 +200,7 @@ export const node = makeLocationNode({
Reference.node,
Skill.node,
Tool.node,
Vcs.node,
PluginHooks.node,
PluginRuntime.node,
WebSearch.node,

View file

@ -24,6 +24,7 @@ import { AbsolutePath, type DeepMutable } from "../schema.js"
import { Skill } from "../skill.js"
import { Tool } from "../tool.js"
import { Workspace } from "../workspace.js"
import { Vcs } from "../vcs.js"
import { WebSearch } from "../websearch.js"
import { PluginHooks } from "./hooks.js"
import type { Interface } from "../plugin.js"
@ -43,6 +44,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
const reference = yield* Reference.Service
const skill = yield* Skill.Service
const tools = yield* Tool.Service
const vcs = yield* Vcs.Service
const websearch = yield* WebSearch.Service
const hooks = yield* PluginHooks.Service
const runtime = yield* PluginRuntime.Service
@ -354,6 +356,14 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: Interface, p
.pipe(Effect.orDie, Effect.as({ dispose: Effect.void })),
hook: (name, callback) => hooks.register("tool", name, callback),
},
vcs: {
get: () => response(vcs.info()),
branches: (input) => response(vcs.branches({ search: input?.search, limit: input?.limit })),
status: () => response(vcs.status()),
diff: (input) => response(vcs.diff(input.mode, { context: input.context })),
transform: vcs.transform,
reload: vcs.reload,
},
websearch: {
providers: () => response(websearch.providers()),
query: (input) =>

View file

@ -1,7 +1,8 @@
export * as Vcs from "./vcs.js"
import path from "path"
import { Context, Effect, Layer, Stream } from "effect"
import { Cause, Context, Effect, Layer, Schema, Stream } from "effect"
import type { VcsDefinition, VcsDraft } from "@opencode-ai/plugin/effect/vcs"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { BranchList, FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
@ -11,8 +12,10 @@ import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "./location.js"
import { AppProcess } from "@opencode-ai/util/process"
import { Bus } from "./bus.js"
import { State } from "./state.js"
import { VcsGit } from "./vcs/git.js"
import { VcsHg } from "./vcs/hg.js"
import { emptyPatch, MAX_TOTAL_PATCH_BYTES, PATCH_CONTEXT_LINES } from "./vcs/patch.js"
export { BranchList, FileStatus, Info, Mode }
@ -25,13 +28,20 @@ export interface BranchOptions {
readonly limit?: number
}
export interface Interface {
export interface Adapter {
readonly info: () => Effect.Effect<Info>
readonly branches: (options?: BranchOptions) => Effect.Effect<BranchList>
readonly status: () => Effect.Effect<FileStatus[]>
readonly diff: (mode: Mode, options?: DiffOptions) => Effect.Effect<FileDiff.Info[]>
}
export interface Interface extends Adapter, State.Transformable<VcsDraft> {}
interface Data {
readonly providers: Map<string, VcsDefinition>
selection?: string
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Vcs") {}
// Adapter seam: one working-copy implementation per VCS type, selected by the
@ -50,11 +60,59 @@ const layer = Layer.effect(
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const bus = yield* Bus.Service
const impl = adapter(proc, fs, location)
const native = adapter(proc, fs, location)
const vcs = location.vcs
const state = { info: impl ? yield* impl.info() : ({ branch: {} } satisfies Info) }
const current = { info: native ? yield* native.info() : ({ branch: {} } satisfies Info) }
const scope = {
directory: location.directory,
worktree: location.project.directory,
canonical: location.project.canonical,
...(vcs ? { store: vcs.store } : {}),
}
const decodeInfo = Schema.decodeUnknownEffect(Info)
const decodeBranches = Schema.decodeUnknownEffect(BranchList)
const decodeStatus = Schema.decodeUnknownEffect(Schema.Array(FileStatus))
const decodeDiff = Schema.decodeUnknownEffect(Schema.Array(FileDiff.Info))
const state: State.Interface<Data, VcsDraft> = State.create<Data, VcsDraft>({
name: "vcs",
initial: () => ({ providers: new Map() }),
draft: (draft) => ({
add: (provider) => draft.providers.set(provider.id, provider),
default: {
get: () => draft.selection,
set: (selection) => (draft.selection = selection),
},
}),
finalize: () => refresh(),
})
const selected = () => {
const value = state.get()
const id = value.selection ?? vcs?.type
return id ? value.providers.get(id) : undefined
}
const protect = <A>(provider: VcsDefinition, operation: string, effect: Effect.Effect<A, unknown>, fallback: A) =>
effect.pipe(
Effect.catchCause((cause) =>
Cause.hasInterrupts(cause)
? Effect.failCause(cause).pipe(Effect.orDie)
: Effect.logWarning("vcs provider failed", { provider: provider.id, operation, cause }).pipe(
Effect.as(fallback),
),
),
)
const refresh = Effect.fn("Vcs.refresh")(function* () {
const provider = selected()
const next: Info = provider
? yield* protect(provider, "info", provider.info(scope).pipe(Effect.flatMap(decodeInfo)), { branch: {} })
: native
? yield* native.info()
: { branch: {} }
const changed = current.info.branch.current !== next.branch.current
current.info = next
if (changed) yield* bus.publish(VcsEvent.BranchUpdated, { branch: next.branch.current })
})
if (vcs && impl) {
if (vcs && native) {
const store = yield* fs.realPath(vcs.store).pipe(Effect.orElseSucceed(() => vcs.store))
const isBranchMetadata =
vcs.type === "git"
@ -63,33 +121,68 @@ const layer = Layer.effect(
yield* bus.subscribe(FileSystem.Event.Changed).pipe(
Stream.filter((event) => isBranchMetadata(event.data.file)),
Stream.runForEach((event) =>
Effect.gen(function* () {
const next = yield* impl.info()
const changed = state.info.branch.current !== next.branch.current
state.info = next
if (!changed) return
yield* bus.publish(VcsEvent.BranchUpdated, { branch: next.branch.current })
}).pipe(Effect.withSpan("Vcs.refreshBranch", { attributes: { file: event.data.file } })),
refresh().pipe(Effect.withSpan("Vcs.refreshBranch", { attributes: { file: event.data.file } })),
),
Effect.forkScoped({ startImmediately: true }),
)
}
return Service.of({
transform: state.transform,
reload: state.reload,
info: Effect.fn("Vcs.info")(function* () {
return state.info
return current.info
}),
branches: Effect.fn("Vcs.branches")(function* (options?: BranchOptions) {
if (!impl) return []
return yield* impl.branches(options)
const provider = selected()
if (provider)
return yield* protect(
provider,
"branches",
provider.branches({ ...scope, ...options }).pipe(Effect.flatMap(decodeBranches)),
[],
)
if (!native) return []
return yield* native.branches(options)
}),
status: Effect.fn("Vcs.status")(function* () {
if (!impl) return []
return yield* impl.status()
const provider = selected()
if (provider)
return yield* protect(
provider,
"status",
provider.status(scope).pipe(
Effect.flatMap(decodeStatus),
Effect.map((rows) => Array.from(rows)),
),
[],
)
if (!native) return []
return yield* native.status()
}),
diff: Effect.fn("Vcs.diff")(function* (mode: Mode, options?: DiffOptions) {
if (!impl) return []
return yield* impl.diff(mode, options)
const provider = selected()
if (!provider) return native ? yield* native.diff(mode, options) : []
const rows = yield* protect(
provider,
"diff",
provider
.diff({
...scope,
mode,
context: options?.context ?? PATCH_CONTEXT_LINES,
maxOutputBytes: MAX_TOTAL_PATCH_BYTES,
})
.pipe(Effect.flatMap(decodeDiff)),
[],
)
let total = 0
return rows.map((row) => {
const bytes = Buffer.byteLength(row.patch)
if (total + bytes > MAX_TOTAL_PATCH_BYTES) return { ...row, patch: emptyPatch(row.file) }
total += bytes
return row
})
}),
})
}),

View file

@ -5,7 +5,7 @@ import { ChildProcess } from "effect/unstable/process"
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { BranchList, FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
import { AppProcess } from "@opencode-ai/util/process"
import type { BranchOptions, DiffOptions, Interface } from "../vcs.js"
import type { Adapter, BranchOptions, DiffOptions } from "../vcs.js"
import { chunksByFile, emptyPatch, MAX_PATCH_BYTES, MAX_TOTAL_PATCH_BYTES, PATCH_CONTEXT_LINES } from "./patch.js"
import type { Patch } from "./patch.js"
@ -14,7 +14,7 @@ import type { Patch } from "./patch.js"
* batched through one `git diff` invocation where possible and capped by
* per-file and total byte budgets, falling back to empty patches when capped.
*/
export function make(proc: AppProcess.Interface, input: { directory: string; worktree: string }): Interface {
export function make(proc: AppProcess.Interface, input: { directory: string; worktree: string }): Adapter {
// Listing commands scope pathspecs to the requested directory; per-file
// commands run from the worktree root because git lists root-relative paths.
const ctx: Ctx = { git: makeGit(proc), directory: input.directory, worktree: input.worktree }
@ -181,20 +181,18 @@ function makeGit(proc: AppProcess.Interface) {
const branches = Effect.fn("VcsGit.branches")(function* (cwd: string, options?: BranchOptions) {
const search = options?.search?.trim().replace(/[*?[\]\\]/g, "\\$&")
return (
yield* lines(
[
"for-each-ref",
"--ignore-case",
"--sort=refname",
"--sort=-committerdate",
"--format=%(refname:short)",
...(options?.limit ? [`--count=${options.limit}`] : []),
...(search ? [`refs/heads/*${search}*`, `refs/remotes/*${search}*`] : ["refs/heads", "refs/remotes"]),
],
{ cwd },
)
).filter((item) => !item.endsWith("/HEAD")) satisfies BranchList
return (yield* lines(
[
"for-each-ref",
"--ignore-case",
"--sort=refname",
"--sort=-committerdate",
"--format=%(refname:short)",
...(options?.limit ? [`--count=${options.limit}`] : []),
...(search ? [`refs/heads/*${search}*`, `refs/remotes/*${search}*`] : ["refs/heads", "refs/remotes"]),
],
{ cwd },
)).filter((item) => !item.endsWith("/HEAD")) satisfies BranchList
})
const defaultBranch = Effect.fn("VcsGit.defaultBranch")(function* (cwd: string) {

View file

@ -7,7 +7,7 @@ import { FileDiff } from "@opencode-ai/schema/file-diff"
import { FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { AppProcess } from "@opencode-ai/util/process"
import type { DiffOptions, Interface } from "../vcs.js"
import type { Adapter, DiffOptions } from "../vcs.js"
import {
addPatch,
chunksByFile,
@ -28,7 +28,7 @@ export function make(
proc: AppProcess.Interface,
fs: FSUtil.Interface,
input: { directory: string; worktree: string },
): Interface {
): Adapter {
const hg = makeHg(proc, input.worktree)
// All commands run from the worktree root (hg prints root-relative paths);
// this pathspec scopes them to the requested directory.

View file

@ -14,6 +14,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { Tool } from "@opencode-ai/core/tool"
import { Vcs } from "@opencode-ai/core/vcs"
import { testEffect } from "./lib/effect"
import { PluginTestLayer } from "./plugin/fixture"
@ -93,6 +94,37 @@ describe("Plugin", () => {
}),
)
it.effect("registers and removes scoped VCS providers", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const vcs = yield* Vcs.Service
const provider = EffectPlugin.define({
id: "custom-vcs",
effect: (ctx) =>
ctx.vcs
.transform((draft) => {
draft.add({
id: "custom",
name: "Custom VCS",
info: () => Effect.succeed({ branch: { current: "feature" } }),
branches: () => Effect.succeed(["feature"]),
status: () => Effect.succeed([]),
diff: () => Effect.succeed([]),
})
draft.default.set("custom")
})
.pipe(Effect.asVoid),
})
yield* plugins.activate([versioned(provider)])
expect(yield* vcs.info()).toEqual({ branch: { current: "feature" } })
expect(yield* vcs.branches()).toEqual(["feature"])
yield* plugins.activate([])
expect(yield* vcs.info()).toEqual({ branch: {} })
}),
)
it.effect("replaces plugins by ID and version", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service

View file

@ -23,6 +23,7 @@ import { Skill } from "@opencode-ai/core/skill"
import { SkillDiscovery } from "@opencode-ai/core/skill/discovery"
import { Watcher } from "@opencode-ai/core/filesystem/watcher"
import { Tool } from "@opencode-ai/core/tool"
import { Vcs } from "@opencode-ai/core/vcs"
import { WebSearch } from "@opencode-ai/core/websearch"
import { Effect, Layer } from "effect"
import { tempLocationLayer } from "../fixture/location"
@ -62,6 +63,7 @@ export const PluginTestLayer = LayerNode.compile(
SkillDiscovery.node,
PluginHooks.node,
Tool.node,
Vcs.node,
Watcher.node,
WebSearch.node,
]),

View file

@ -107,6 +107,14 @@ export function host(overrides: Overrides = {}): Plugin.Context {
transform: () => Effect.die("unused tool.transform"),
hook: () => Effect.die("unused tool.hook"),
},
vcs: overrides.vcs ?? {
get: () => Effect.die("unused vcs.get"),
branches: () => Effect.die("unused vcs.branches"),
status: () => Effect.die("unused vcs.status"),
diff: () => Effect.die("unused vcs.diff"),
transform: () => Effect.die("unused vcs.transform"),
reload: () => Effect.die("unused vcs.reload"),
},
websearch: overrides.websearch ?? {
providers: () => Effect.die("unused websearch.providers"),
query: () => Effect.die("unused websearch.query"),

View file

@ -10,6 +10,7 @@ import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { PluginPromise } from "@opencode-ai/core/plugin/promise"
import { WebSearch } from "@opencode-ai/core/websearch"
import { Vcs } from "@opencode-ai/core/vcs"
import { Session } from "@opencode-ai/core/session"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionInbox } from "@opencode-ai/core/session/inbox"
@ -432,6 +433,56 @@ describe("fromPromise", () => {
}),
)
it.effect("registers a Promise VCS provider and forwards client reads", () =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
const signals: AbortSignal[] = []
const promisePlugin = define({
id: "promise-vcs",
setup: async (ctx) => {
await ctx.vcs.transform((draft) => {
draft.add({
id: "custom",
name: "Custom VCS",
info: async (_input, request) => {
signals.push(request.signal)
return { branch: { current: "feature", default: "main" } }
},
branches: async (input, request) => {
signals.push(request.signal)
expect(input.search).toBe("feat")
return ["feature"]
},
status: async (_input, request) => {
signals.push(request.signal)
return [{ file: "file.txt", additions: 1, deletions: 0, status: "added" }]
},
diff: async (input, request) => {
signals.push(request.signal)
expect(input.context).toBe(2)
expect(input.maxOutputBytes).toBe(10_000_000)
return [{ file: "file.txt", patch: "+hello", additions: 1, deletions: 0, status: "added" }]
},
})
draft.default.set("custom")
})
expect((await ctx.vcs.get()).data.branch.current).toBe("feature")
expect((await ctx.vcs.branches({ search: "feat" })).data).toEqual(["feature"])
expect((await ctx.vcs.status()).data).toHaveLength(1)
expect((await ctx.vcs.diff({ mode: "working", context: 2 })).data[0].patch).toBe("+hello")
},
})
yield* PluginPromise.fromPromise(promisePlugin).effect(host)
expect((yield* vcs.info()).branch.current).toBe("feature")
expect(signals).toHaveLength(4)
expect(signals.every((signal) => signal instanceof AbortSignal)).toBeTrue()
}),
)
it.effect("registers a standalone web search provider", () =>
Effect.gen(function* () {
const websearch = yield* WebSearch.Service

View file

@ -2,12 +2,13 @@ import { $ } from "bun"
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { Effect, Fiber, Layer, Stream } from "effect"
import { Cause, Effect, Exit, Fiber, Layer, Stream } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Vcs } from "@opencode-ai/core/vcs"
import type { VcsDefinition, VcsDiffInput } from "@opencode-ai/plugin/effect/vcs"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { VcsEvent } from "@opencode-ai/schema/vcs-event"
import { location } from "./fixture/location"
@ -58,6 +59,17 @@ async function commitAll(directory: string, message: string) {
await $`git commit -m ${message}`.cwd(directory).quiet()
}
const provider = (input: Partial<VcsDefinition> = {}) =>
({
id: "custom",
name: "Custom VCS",
info: () => Effect.succeed({ branch: { current: "feature", default: "main" } }),
branches: () => Effect.succeed(["feature", "main"]),
status: () => Effect.succeed([{ file: "file.txt", additions: 1, deletions: 0, status: "added" }]),
diff: () => Effect.succeed([{ file: "file.txt", patch: "+hello", additions: 1, deletions: 0, status: "added" }]),
...input,
}) satisfies VcsDefinition
describe("Vcs", () => {
it.live("returns empty results outside version control", () =>
withTmp((directory) =>
@ -72,6 +84,118 @@ describe("Vcs", () => {
),
)
it.live("serves scoped providers and restores the fallback after disposal", () =>
withTmp((directory) =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
const registration = yield* vcs.transform((draft) => {
draft.add(provider())
draft.default.set("custom")
})
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
expect(yield* vcs.branches()).toEqual(["feature", "main"])
expect(yield* vcs.status()).toEqual([{ file: "file.txt", additions: 1, deletions: 0, status: "added" }])
expect(yield* vcs.diff("working")).toEqual([
{ file: "file.txt", patch: "+hello", additions: 1, deletions: 0, status: "added" },
])
yield* registration.dispose
expect(yield* vcs.info()).toEqual({ branch: {} })
expect(yield* vcs.status()).toEqual([])
}).pipe(provide(directory)),
),
)
it.live("automatically selects a provider matching the resolved repository", () =>
withGit(() =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
const registration = yield* vcs.transform((draft) => draft.add(provider({ id: "git" })))
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
yield* registration.dispose
expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: undefined } })
}),
),
)
it.live("passes location scope and bounded diff options to providers", () =>
withTmp((directory) =>
Effect.gen(function* () {
const observed: VcsDiffInput[] = []
const vcs = yield* Vcs.Service
yield* vcs.transform((draft) => {
draft.add(
provider({
diff: (input) =>
Effect.sync(() => {
observed.push(input)
return [{ file: "file.txt", patch: "+hello", additions: 1, deletions: 0, status: "added" }]
}),
}),
)
draft.default.set("custom")
})
yield* vcs.diff("branch", { context: 3 })
expect(observed).toEqual([
{
directory,
worktree: directory,
canonical: directory,
mode: "branch",
context: 3,
maxOutputBytes: 10_000_000,
},
])
}).pipe(provide(directory)),
),
)
it.live("validates provider results and bounds oversized patches", () =>
withTmp((directory) =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
yield* vcs.transform((draft) => {
draft.add(
provider({
status: () => Effect.succeed([{ file: "file.txt", additions: -1, deletions: 0, status: "added" }]),
diff: () =>
Effect.succeed([
{ file: "file.txt", patch: "x".repeat(10_000_001), additions: 1, deletions: 0, status: "added" },
]),
}),
)
draft.default.set("custom")
})
expect(yield* vcs.status()).toEqual([])
const rows = yield* vcs.diff("working")
expect(rows).toHaveLength(1)
expect(Buffer.byteLength(rows[0].patch)).toBeLessThan(1000)
expect(rows[0].additions).toBe(1)
}).pipe(provide(directory)),
),
)
it.live("preserves provider interruption", () =>
withTmp((directory) =>
Effect.gen(function* () {
const vcs = yield* Vcs.Service
yield* vcs.transform((draft) => {
draft.add(provider({ status: () => Effect.never }))
draft.default.set("custom")
})
const fiber = yield* Effect.forkChild(vcs.status())
yield* Fiber.interrupt(fiber)
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)).toBeTrue()
}).pipe(provide(directory)),
),
)
it.live("lists local branches by recent activity", () =>
withGit((directory) =>
Effect.gen(function* () {
@ -83,7 +207,11 @@ describe("Vcs", () => {
await $`git add -A`.cwd(directory).quiet()
await $`git commit -m recent`
.cwd(directory)
.env({ ...process.env, GIT_AUTHOR_DATE: "2030-01-01T00:00:00Z", GIT_COMMITTER_DATE: "2030-01-01T00:00:00Z" })
.env({
...process.env,
GIT_AUTHOR_DATE: "2030-01-01T00:00:00Z",
GIT_COMMITTER_DATE: "2030-01-01T00:00:00Z",
})
.quiet()
})
const vcs = yield* Vcs.Service

View file

@ -11,4 +11,5 @@ export { Model } from "@opencode-ai/schema/model"
export { Provider } from "@opencode-ai/schema/provider"
export { Reference } from "@opencode-ai/schema/reference"
export { Skill } from "@opencode-ai/schema/skill"
export { Vcs } from "@opencode-ai/schema/vcs"
export { WebSearch } from "@opencode-ai/schema/websearch"

View file

@ -15,6 +15,7 @@ import type { ShellDomain } from "./shell.js"
import type { SkillDomain } from "./skill.js"
import type { StorageDomain } from "./storage.js"
import type { ToolDomain } from "./tool.js"
import type { VcsDomain } from "./vcs.js"
import type { WebSearchDomain } from "./websearch.js"
export interface Context {
@ -34,6 +35,7 @@ export interface Context {
readonly skill: SkillDomain
readonly storage: StorageDomain
readonly tool: ToolDomain
readonly vcs: VcsDomain
readonly websearch: WebSearchDomain
}

View file

@ -0,0 +1,45 @@
import type { VcsApi } from "@opencode-ai/client/effect/api"
import type { FileDiff } from "@opencode-ai/schema/file-diff"
import type { Vcs } from "@opencode-ai/schema/vcs"
import type { Effect } from "effect"
import type { Transform } from "./registration.js"
export interface VcsScope {
readonly directory: string
readonly worktree: string
readonly canonical: string
readonly store?: string
}
export interface VcsBranchesInput extends VcsScope {
readonly search?: string
readonly limit?: number
}
export interface VcsDiffInput extends VcsScope {
readonly mode: Vcs.Mode
readonly context: number
readonly maxOutputBytes: number
}
export interface VcsDefinition {
readonly id: string
readonly name: string
readonly info: (input: VcsScope) => Effect.Effect<Vcs.Info, unknown>
readonly branches: (input: VcsBranchesInput) => Effect.Effect<Vcs.BranchList, unknown>
readonly status: (input: VcsScope) => Effect.Effect<readonly Vcs.FileStatus[], unknown>
readonly diff: (input: VcsDiffInput) => Effect.Effect<readonly FileDiff.Info[], unknown>
}
export interface VcsDomain extends VcsApi<unknown> {
readonly transform: Transform<VcsDraft>
readonly reload: () => Effect.Effect<void>
}
export interface VcsDraft {
add(definition: VcsDefinition): void
readonly default: {
get(): string | undefined
set(selection: string): void
}
}

View file

@ -84,6 +84,7 @@ export function fromPromise(plugin: Plugin) {
const ReferenceEndpoints = ClientApi.groups["server.reference"].endpoints
const SessionEndpoints = ClientApi.groups["server.session"].endpoints
const SkillEndpoints = ClientApi.groups["server.skill"].endpoints
const VcsEndpoints = ClientApi.groups["server.vcs"].endpoints
const WebSearchEndpoints = ClientApi.groups["server.websearch"].endpoints
const context = yield* Effect.context<Scope.Scope>()
@ -295,6 +296,30 @@ export function fromPromise(plugin: Plugin) {
hook: (name, callback) =>
register(host.tool.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
},
vcs: {
get: adaptApiMethod(VcsEndpoints["vcs.get"], host.vcs.get),
branches: adaptApiMethod(VcsEndpoints["vcs.branches"], host.vcs.branches),
status: adaptApiMethod(VcsEndpoints["vcs.status"], host.vcs.status),
diff: adaptApiMethod(VcsEndpoints["vcs.diff"], host.vcs.diff),
reload: () => run(host.vcs.reload()),
transform: (callback) =>
register(
host.vcs.transform((draft) => {
callback({
add: (definition) =>
draft.add({
id: definition.id,
name: definition.name,
info: (input) => attempt((signal) => definition.info(input, { signal })),
branches: (input) => attempt((signal) => definition.branches(input, { signal })),
status: (input) => attempt((signal) => definition.status(input, { signal })),
diff: (input) => attempt((signal) => definition.diff(input, { signal })),
}),
default: draft.default,
})
}),
),
},
websearch: {
providers: adaptApiMethod(WebSearchEndpoints["websearch.providers"], host.websearch.providers),
query: adaptApiMethod(WebSearchEndpoints["websearch.query"], host.websearch.query),

View file

@ -12,4 +12,5 @@ export { Model } from "@opencode-ai/schema/model"
export { Provider } from "@opencode-ai/schema/provider"
export { Reference } from "@opencode-ai/schema/reference"
export { Skill } from "@opencode-ai/schema/skill"
export { Vcs } from "@opencode-ai/schema/vcs"
export { WebSearch } from "@opencode-ai/schema/websearch"

View file

@ -14,6 +14,7 @@ import type { ShellDomain } from "./shell.js"
import type { SkillDomain } from "./skill.js"
import type { StorageDomain } from "./storage.js"
import type { ToolDomain } from "./tool.js"
import type { VcsDomain } from "./vcs.js"
import type { WebSearchDomain } from "./websearch.js"
export interface Context {
@ -33,6 +34,7 @@ export interface Context {
readonly skill: SkillDomain
readonly storage: StorageDomain
readonly tool: ToolDomain
readonly vcs: VcsDomain
readonly websearch: WebSearchDomain
}

View file

@ -0,0 +1,44 @@
import type { VcsApi } from "@opencode-ai/client/promise/api"
import type { FileDiff } from "@opencode-ai/schema/file-diff"
import type { Vcs } from "@opencode-ai/schema/vcs"
import type { Transform } from "./registration.js"
export interface VcsScope {
readonly directory: string
readonly worktree: string
readonly canonical: string
readonly store?: string
}
export interface VcsBranchesInput extends VcsScope {
readonly search?: string
readonly limit?: number
}
export interface VcsDiffInput extends VcsScope {
readonly mode: Vcs.Mode
readonly context: number
readonly maxOutputBytes: number
}
export interface VcsDefinition {
readonly id: string
readonly name: string
readonly info: (input: VcsScope, context: { readonly signal: AbortSignal }) => Promise<Vcs.Info>
readonly branches: (input: VcsBranchesInput, context: { readonly signal: AbortSignal }) => Promise<Vcs.BranchList>
readonly status: (input: VcsScope, context: { readonly signal: AbortSignal }) => Promise<readonly Vcs.FileStatus[]>
readonly diff: (input: VcsDiffInput, context: { readonly signal: AbortSignal }) => Promise<readonly FileDiff.Info[]>
}
export interface VcsDomain extends VcsApi {
readonly transform: Transform<VcsDraft>
readonly reload: () => Promise<void>
}
export interface VcsDraft {
add(definition: VcsDefinition): void
readonly default: {
get(): string | undefined
set(selection: string): void
}
}

View file

@ -9,6 +9,7 @@ import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
import { Reference } from "@opencode-ai/schema/reference"
import { Skill } from "@opencode-ai/schema/skill"
import { Vcs } from "@opencode-ai/schema/vcs"
import { WebSearch } from "@opencode-ai/schema/websearch"
const Plugin = await import("../src/effect/index")
@ -29,6 +30,7 @@ test.each([
expect(entrypoint.Provider).toBe(Provider)
expect(entrypoint.Reference).toBe(Reference)
expect(entrypoint.Skill).toBe(Skill)
expect(entrypoint.Vcs).toBe(Vcs)
expect(entrypoint.WebSearch).toBe(WebSearch)
expect(Object.keys(entrypoint).sort()).toEqual([
"Agent",
@ -42,6 +44,7 @@ test.each([
"Provider",
"Reference",
"Skill",
"Vcs",
"WebSearch",
])
})

View file

@ -95,6 +95,8 @@ const unavailable = (what: string) => Effect.die(new Error(`${what} is unavailab
const vcsLayer = Layer.succeed(
Vcs.Service,
Vcs.Service.of({
transform: () => Effect.succeed({ dispose: Effect.void }),
reload: () => Effect.void,
info: () => Effect.succeed({ branch: {} }),
branches: () => Effect.succeed([]),
status: () => Effect.succeed([]),

View file

@ -125,6 +125,7 @@ interface Context {
readonly skill: SkillDomain
readonly storage: StorageDomain
readonly tool: ToolDomain
readonly vcs: VcsDomain
readonly websearch: WebSearchDomain
}
@ -853,6 +854,65 @@ interface ToolDomain {
}
```
### VCS
Read repository information, working-copy status, or file diffs.
```ts
effect: (ctx) =>
Effect.gen(function* () {
const vcs = ctx.vcs
const info = yield* vcs.get().pipe(Effect.orDie)
const branches = yield* vcs.branches({ search: "feature", limit: 10 }).pipe(Effect.orDie)
const changes = yield* vcs.status().pipe(Effect.orDie)
const diff = yield* vcs.diff({ mode: "working", context: 3 }).pipe(Effect.orDie)
yield* Effect.logInfo("vcs", { branch: info.data.branch.current, files: changes.data.length })
}),
```
Register a location-scoped provider through a scoped transform. Providers matching the detected repository type are
selected automatically; use `draft.default.set` to select a different provider.
```ts
effect: (ctx) =>
Effect.gen(function* () {
const vcs = ctx.vcs
yield* vcs.transform((draft) => {
draft.add({
id: "custom",
name: "Custom VCS",
info: () => Effect.succeed({ branch: { current: "feature", default: "main" } }),
branches: (input) => readBranches(input),
status: (scope) => readStatus(scope.worktree),
diff: (input) => readDiff(input),
})
draft.default.set("custom")
})
}),
```
Provider callbacks receive the current location, working-copy root, canonical project root, and optional repository
store. Diff callbacks also receive the selected mode, requested context, and output byte budget. Repository discovery
continues to use OpenCode's built-in Git and Mercurial detectors.
Schemas: [`Vcs.Info`](/api#schema-Vcs.Info), [`Vcs.FileStatus`](/api#schema-Vcs.FileStatus),
[`FileDiff.Info`](/api#schema-FileDiff.Info).
```ts
interface VcsDraft {
add(definition: VcsDefinition): void
readonly default: {
get(): string | undefined
set(selection: string): void
}
}
interface VcsDomain extends VcsApi<unknown> {
readonly transform: Transform<VcsDraft>
readonly reload: () => Effect.Effect<void>
}
```
### Websearch
List providers or run a query through the selected provider.

View file

@ -797,6 +797,62 @@ interface ToolDraft {
}
```
### VCS
Read repository information, working-copy status, or file diffs.
```ts
const info = await ctx.vcs.get()
const branches = await ctx.vcs.branches({ search: "feature", limit: 10 })
const changes = await ctx.vcs.status()
const diff = await ctx.vcs.diff({ mode: "working", context: 3 })
```
Register a location-scoped VCS provider with a transform. A provider matching the detected repository type is selected
automatically; use `draft.default.set` to select a different provider.
```ts
await ctx.vcs.transform((draft) => {
draft.add({
id: "custom",
name: "Custom VCS",
info: async () => ({ branch: { current: "feature", default: "main" } }),
branches: async (input, { signal }) => readBranches(input, signal),
status: async (scope, { signal }) => readStatus(scope.worktree, signal),
diff: async (input, { signal }) => readDiff(input, signal),
})
draft.default.set("custom")
})
```
Provider callbacks receive the current location, working-copy root, canonical project root, and optional repository
store. Diff callbacks also receive the selected mode, requested context, and output byte budget. Repository discovery
continues to use OpenCode's built-in Git and Mercurial detectors.
#### Reference
Schemas: [`Vcs.Info`](/api#schema-Vcs.Info), [`Vcs.FileStatus`](/api#schema-Vcs.FileStatus),
[`FileDiff.Info`](/api#schema-FileDiff.Info)
```ts
interface VcsContext {
get(input?: VcsGetInput, requestOptions?: RequestOptions): Promise<VcsGetOutput>
branches(input?: VcsBranchesInput, requestOptions?: RequestOptions): Promise<VcsBranchesOutput>
status(input?: VcsStatusInput, requestOptions?: RequestOptions): Promise<VcsStatusOutput>
diff(input: VcsDiffInput, requestOptions?: RequestOptions): Promise<VcsDiffOutput>
transform(callback: (draft: VcsDraft) => void): Promise<Registration>
reload(): Promise<void>
}
interface VcsDraft {
add(definition: VcsDefinition): void
default: {
get(): string | undefined
set(providerID: string): void
}
}
```
### Websearch
List websearch providers or run a query through the selected provider.