feat(plugin): add declarative vcs repository markers (#45192)

This commit is contained in:
Shoubhit Dash
2026-08-26 13:49:36 +05:30
committed by GitHub
parent 0f7a76eff0
commit 4eaf533cd0
22 changed files with 502 additions and 151 deletions
@@ -293,7 +293,7 @@ export type McpResourceTemplate = {
mimeType?: string
}
export type ProjectVcs = "git" | "hg"
export type ProjectVcs = string
export type ProjectIcon = { url?: string; override?: string; color?: string }
+7 -68
View File
@@ -4,12 +4,13 @@ import { Directory, Document, type Entry } from "@opencode-ai/schema/config"
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Option, Predicate, PubSub, Schema, Scope, Stream } from "effect"
import { Context, Effect, Layer, Option, PubSub, Scope, Stream } from "effect"
import path from "path"
import { fileURLToPath } from "url"
import { Config } from "../../config.js"
import { Watcher } from "../../filesystem/watcher.js"
import { Location } from "../../location.js"
import { PluginSourceDirectory } from "../../plugin/source-directory.js"
export type Operation =
| {
@@ -124,7 +125,10 @@ const scan = Effect.fn("ConfigPluginSource.scan")(function* (
) {
const discovered = yield* Effect.forEach(
entries.filter((entry): entry is Directory => entry.type === "directory"),
(entry) => discoverDirectory(fs, entry.path),
(entry) =>
PluginSourceDirectory.discover(fs, entry.path).pipe(
Effect.map((targets) => targets.map((target): Operation => ({ type: "add", target, options: {} }))),
),
).pipe(Effect.map((items) => items.flat()))
const configured = entries
.filter((entry): entry is Document => entry.type === "document")
@@ -153,75 +157,10 @@ const scan = Effect.fn("ConfigPluginSource.scan")(function* (
})
})
const sourceDirectories = ["plugin", "plugins"] as const
const Package = Schema.Struct({
exports: Schema.optional(Schema.Unknown),
module: Schema.optional(Schema.Unknown),
main: Schema.optional(Schema.Unknown),
})
const decodePackage = Schema.decodeUnknownOption(Package)
function discoverDirectory(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const children = (yield* Effect.forEach(sourceDirectories, (source) =>
fs.readDirectoryEntries(path.join(directory, source)).pipe(
Effect.orElseSucceed(() => []),
Effect.map((entries) =>
entries.map((entry) => ({ ...entry, target: path.join(directory, source, entry.name) })),
),
),
))
.flat()
.sort((a, b) => (a.target < b.target ? -1 : a.target > b.target ? 1 : 0))
const targets = yield* Effect.forEach(children, (entry) => discoverChild(fs, entry))
return targets.flatMap(Option.toArray).map((target): Operation => ({ type: "add", target, options: {} }))
})
}
function discoverChild(fs: FSUtil.Interface, entry: FSUtil.DirEntry & { target: string }) {
return Effect.gen(function* () {
const source = entry.target.endsWith(".ts") || entry.target.endsWith(".js")
if (entry.type === "file" && source) return Option.some(entry.target)
if (entry.type === "directory") return yield* discoverPackage(fs, entry.target)
if (entry.type !== "symlink") return Option.none<string>()
if (source && (yield* fs.isFile(entry.target))) return Option.some(entry.target)
if (yield* fs.isDir(entry.target)) return yield* discoverPackage(fs, entry.target)
return Option.none<string>()
})
}
function discoverPackage(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const root = yield* fs.resolve(directory)
const manifest = yield* fs
.readJson(path.join(directory, "package.json"))
.pipe(Effect.map(decodePackage), Effect.orElseSucceed(Option.none))
const configured = Option.isSome(manifest)
? [manifest.value.exports, manifest.value.module, manifest.value.main].filter(Predicate.isString)
: []
return yield* Effect.findFirst(
[...configured, "index.ts", "index.js"]
.filter((entry) => !path.isAbsolute(entry))
.map((entry) => path.resolve(directory, entry))
.filter((entry) => FSUtil.contains(directory, entry)),
(entry) =>
fs
.isFile(entry)
.pipe(
Effect.flatMap((exists) =>
exists
? fs.resolve(entry).pipe(Effect.map((resolved) => FSUtil.contains(root, resolved)))
: Effect.succeed(false),
),
),
)
})
}
function isPluginSource(entries: readonly Entry[], file: string) {
return entries.some(
(entry) =>
entry.type === "directory" &&
sourceDirectories.some((directory) => FSUtil.contains(path.join(entry.path, directory), file)),
PluginSourceDirectory.names.some((directory) => FSUtil.contains(path.join(entry.path, directory), file)),
)
}
+7 -2
View File
@@ -6,6 +6,7 @@ import { Context, Effect, Layer, Schema } from "effect"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "./location.js"
import { Project } from "./project.js"
import { ProjectMarkers } from "./project/markers.js"
import { AbsolutePath } from "./schema.js"
export const Kind = Schema.Literals(["file", "directory"])
@@ -64,6 +65,7 @@ const layer = Layer.effect(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const location = yield* Location.Service
const markers = yield* ProjectMarkers.Service
const resolve = Effect.fnUntraced(function* (input: ResolveInput) {
const absolute = path.resolve(location.directory, input.path)
@@ -90,7 +92,10 @@ const layer = Layer.effect(
directory: externalDirectory,
resource: externalResource,
save: slash(
path.join((yield* Project.root(fs, AbsolutePath.make(externalDirectory))) ?? externalDirectory, "*"),
path.join(
(yield* Project.root(fs, AbsolutePath.make(externalDirectory), markers.targets())) ?? externalDirectory,
"*",
),
),
},
} satisfies Target
@@ -103,5 +108,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer: layer.pipe(Layer.orDie),
deps: [FSUtil.node, Location.node],
deps: [FSUtil.node, Location.node, ProjectMarkers.node],
})
+2
View File
@@ -10,6 +10,7 @@ export { Info, Ref, response }
export interface Interface extends Info {
readonly vcs?: Project.Vcs
readonly vcsBackend?: string
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Location") {}
@@ -27,6 +28,7 @@ const layer = (ref: Ref) =>
workspaceID: ref.workspaceID,
project: { id: resolved.id, directory: resolved.directory, canonical: resolved.canonical },
vcs: resolved.vcs,
vcsBackend: resolved.vcsBackend,
})
}),
)
+1
View File
@@ -287,6 +287,7 @@ export const list = Effect.fn("PluginInternal.list")(function* () {
plugins.map(
(plugin): Plugin => ({
id: plugin.id,
vcs: plugin.vcs,
effect: (host) => plugin.effect(host).pipe(Effect.provide(context)),
}),
)
+62
View File
@@ -0,0 +1,62 @@
export * as PluginModule from "./module.js"
import type { Plugin } from "@opencode-ai/plugin/effect/plugin"
import { Npm } from "@opencode-ai/util/npm"
import { importModule } from "@opencode-ai/util/runtime-import"
import { Effect, Schema } from "effect"
import path from "path"
import { pathToFileURL } from "url"
import type { ConfigPluginSource } from "../config/plugin/source.js"
import type { Versioned } from "../plugin.js"
import { PluginPromise } from "./promise.js"
const Discovery = Schema.Struct({
id: Schema.optional(Schema.String),
markers: Schema.Array(Schema.String),
})
const Definition = Schema.Struct({
default: Schema.Union([
Schema.Struct({
id: Schema.String,
tui: Schema.optional(Schema.Boolean),
vcs: Schema.optional(Discovery),
effect: Schema.declare<Plugin["effect"]>((input): input is Plugin["effect"] => typeof input === "function"),
}),
Schema.Struct({
id: Schema.String,
tui: Schema.optional(Schema.Boolean),
vcs: Schema.optional(Discovery),
setup: Schema.declare<Parameters<typeof PluginPromise.fromPromise>[0]["setup"]>(
(input): input is Parameters<typeof PluginPromise.fromPromise>[0]["setup"] => typeof input === "function",
),
}),
]),
})
export const load = Effect.fn("PluginModule.load")(function* (
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
) {
const npm = yield* Npm.Service
const entrypoint = path.isAbsolute(operation.target)
? pathToFileURL(operation.target).href
: (yield* npm.add(operation.target, { subpaths: ["server", ""], refresh: true })).entrypoint
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
// Bun currently ignores query parameters when caching file:// imports.
const target = typeof Bun !== "undefined" ? operation.target.replaceAll("\\", "/") : entrypoint
const source = operation.mtime === undefined ? entrypoint : `${target}?mtime=${operation.mtime}`
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
const mod = yield* Effect.promise(() => importModule(source))
const value = (yield* Schema.decodeUnknownEffect(Definition)(mod)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
return {
id: plugin.id,
tui: plugin.tui,
vcs: plugin.vcs,
version: JSON.stringify(operation),
source: path.isAbsolute(operation.target)
? { type: "local" as const, path: operation.target }
: { type: "package" as const, package: operation.target },
effect: (host) => plugin.effect({ ...host, options: operation.options }),
} satisfies Versioned
})
@@ -0,0 +1,68 @@
export * as PluginSourceDirectory from "./source-directory.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Effect, Option, Predicate, Schema } from "effect"
import path from "path"
export const names = ["plugin", "plugins"] as const
const Package = Schema.Struct({
exports: Schema.optional(Schema.Unknown),
module: Schema.optional(Schema.Unknown),
main: Schema.optional(Schema.Unknown),
})
const decodePackage = Schema.decodeUnknownOption(Package)
export const discover = Effect.fn("PluginSourceDirectory.discover")(function* (
fs: FSUtil.Interface,
directory: string,
) {
const children = (yield* Effect.forEach(names, (source) =>
fs.readDirectoryEntries(path.join(directory, source)).pipe(
Effect.orElseSucceed(() => []),
Effect.map((entries) => entries.map((entry) => ({ ...entry, target: path.join(directory, source, entry.name) }))),
),
))
.flat()
.sort((a, b) => (a.target < b.target ? -1 : a.target > b.target ? 1 : 0))
const targets = yield* Effect.forEach(children, (entry) =>
Effect.gen(function* () {
const source = entry.target.endsWith(".ts") || entry.target.endsWith(".js")
if (entry.type === "file" && source) return Option.some(entry.target)
if (entry.type === "directory") return yield* packageEntry(fs, entry.target)
if (entry.type !== "symlink") return Option.none<string>()
if (source && (yield* fs.isFile(entry.target))) return Option.some(entry.target)
if (yield* fs.isDir(entry.target)) return yield* packageEntry(fs, entry.target)
return Option.none<string>()
}),
)
return targets.flatMap(Option.toArray)
})
function packageEntry(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const root = yield* fs.resolve(directory)
const manifest = yield* fs
.readJson(path.join(directory, "package.json"))
.pipe(Effect.map(decodePackage), Effect.orElseSucceed(Option.none))
const configured = Option.isSome(manifest)
? [manifest.value.exports, manifest.value.module, manifest.value.main].filter(Predicate.isString)
: []
return yield* Effect.findFirst(
[...configured, "index.ts", "index.js"]
.filter((entry) => !path.isAbsolute(entry))
.map((entry) => path.resolve(directory, entry))
.filter((entry) => FSUtil.contains(directory, entry)),
(entry) =>
fs
.isFile(entry)
.pipe(
Effect.flatMap((exists) =>
exists
? fs.resolve(entry).pipe(Effect.map((resolved) => FSUtil.contains(root, resolved)))
: Effect.succeed(false),
),
),
)
})
}
+3 -53
View File
@@ -1,41 +1,19 @@
export * as PluginSupervisor from "./supervisor.js"
export { Service, type Interface } from "./supervisor-service.js"
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
import { Event } from "@opencode-ai/schema/config"
import { Cause, Effect, Latch, Layer, Schema, Stream } from "effect"
import { Cause, Effect, Latch, Layer, Stream } from "effect"
import path from "path"
import { pathToFileURL } from "url"
import { ConfigPluginSource } from "../config/plugin/source.js"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "../bus.js"
import { Npm } from "@opencode-ai/util/npm"
import { Plugin } from "../plugin.js"
import { PluginPromise } from "../plugin/promise.js"
import { PluginInternal } from "./internal.js"
import { PluginModule } from "./module.js"
import { SdkPlugins } from "./sdk.js"
import { importModule } from "@opencode-ai/util/runtime-import"
import { Service } from "./supervisor-service.js"
const PluginModule = Schema.Struct({
default: Schema.Union([
Schema.Struct({
id: Schema.String,
tui: Schema.optional(Schema.Boolean),
effect: Schema.declare<PluginDefinition["effect"]>(
(input): input is PluginDefinition["effect"] => typeof input === "function",
),
}),
Schema.Struct({
id: Schema.String,
tui: Schema.optional(Schema.Boolean),
setup: Schema.declare<Parameters<typeof PluginPromise.fromPromise>[0]["setup"]>(
(input): input is Parameters<typeof PluginPromise.fromPromise>[0]["setup"] => typeof input === "function",
),
}),
]),
})
const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
pre: readonly Plugin.Versioned[],
post: readonly Plugin.Versioned[],
@@ -69,7 +47,7 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
continue
}
const plugin = yield* load(operation).pipe(
const plugin = yield* PluginModule.load(operation).pipe(
Effect.catchCause((cause) =>
Effect.logWarning("failed to load plugin", { target: operation.target, cause }).pipe(
Effect.as({ error: Cause.pretty(cause) }),
@@ -102,34 +80,6 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
}
})
const load = Effect.fn("PluginSupervisor.load")(function* (
operation: Extract<ConfigPluginSource.Operation, { type: "add" }>,
) {
const npm = yield* Npm.Service
const entrypoint = path.isAbsolute(operation.target)
? pathToFileURL(operation.target).href
: (yield* npm.add(operation.target, { subpaths: ["server", ""], refresh: true })).entrypoint
if (!entrypoint) return yield* Effect.fail(new Error(`Plugin entrypoint not found: ${operation.target}`))
// Bun currently ignores query parameters when caching file:// imports.
const source =
operation.mtime === undefined
? entrypoint
: typeof Bun !== "undefined"
? `${operation.target.replaceAll("\\", "/")}?mtime=${operation.mtime}`
: `${entrypoint}?mtime=${operation.mtime}`
yield* Effect.log({ msg: "loading plugin", id: operation.target, entrypoint: source })
const mod = yield* Effect.promise(() => importModule(source))
const value = (yield* Schema.decodeUnknownEffect(PluginModule)(mod)).default
const plugin = "effect" in value ? value : PluginPromise.fromPromise(value)
return {
id: plugin.id,
tui: plugin.tui,
version: JSON.stringify(operation),
source: pluginSource(operation.target),
effect: (host) => plugin.effect({ ...host, options: operation.options }),
} satisfies Plugin.Versioned
})
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
+8 -1
View File
@@ -8,11 +8,18 @@ import { BranchList, FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
import { AppProcess } from "@opencode-ai/util/process"
import { Location } from "../../location.js"
import type { Adapter, BranchOptions, DiffOptions } from "../../vcs.js"
import { chunksByFile, emptyPatch, MAX_PATCH_BYTES, MAX_TOTAL_PATCH_BYTES, PATCH_CONTEXT_LINES } from "../../vcs/patch.js"
import {
chunksByFile,
emptyPatch,
MAX_PATCH_BYTES,
MAX_TOTAL_PATCH_BYTES,
PATCH_CONTEXT_LINES,
} from "../../vcs/patch.js"
import type { Patch } from "../../vcs/patch.js"
export const Plugin = define({
id: "opencode.vcs.git",
vcs: { id: "git", markers: [".git"] },
effect: Effect.fn("VcsGitPlugin")(function* (ctx) {
const location = yield* Location.Service
if (location.vcs?.type !== "git") return
+1
View File
@@ -23,6 +23,7 @@ import {
export const Plugin = define({
id: "opencode.vcs.hg",
vcs: { id: "hg", markers: [".hg"] },
effect: Effect.fn("VcsHgPlugin")(function* (ctx) {
const location = yield* Location.Service
if (location.vcs?.type !== "hg") return
+40 -13
View File
@@ -13,6 +13,7 @@ import { Git } from "./git.js"
import { AppProcess } from "@opencode-ai/util/process"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Hash } from "@opencode-ai/util/hash"
import { ProjectMarkers } from "./project/markers.js"
import { ProjectSchema } from "./project/schema.js"
import { ProjectTable, upsertProject } from "./project/sql.js"
import { WorktreeTable } from "./worktree/sql.js"
@@ -42,11 +43,16 @@ export interface Resolved {
readonly directory: AbsolutePath
readonly canonical: AbsolutePath
readonly vcs?: Vcs
readonly vcsBackend?: string
}
// Keep this filesystem-only; permission checks use it and should not execute VCS commands.
export const root = Effect.fn("Project.root")(function* (fs: FSUtil.Interface, input: AbsolutePath) {
return yield* fs.up({ targets: [".git", ".hg"], start: input, mode: "first" }).pipe(
export const root = Effect.fn("Project.root")(function* (
fs: FSUtil.Interface,
input: AbsolutePath,
markers: readonly string[] = [".git", ".hg"],
) {
return yield* fs.up({ targets: [...markers], start: input, mode: "first" }).pipe(
Effect.map((matches) => (matches[0] ? AbsolutePath.make(path.dirname(matches[0])) : undefined)),
Effect.orElseSucceed(() => undefined),
)
@@ -90,6 +96,7 @@ const layer = Layer.effect(
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const git = yield* Git.Service
const markers = yield* ProjectMarkers.Service
const proc = yield* AppProcess.Service
const bus = yield* Bus.Service
const db = (yield* Database.Service).db
@@ -157,11 +164,11 @@ const layer = Layer.effect(
Effect.gen(function* () {
if (candidate.id === item.projectID) return false
if (!FSUtil.contains(directory, candidate.directory)) return false
const markers = yield* fs
.up({ targets: [".git", ".hg"], start: candidate.directory, stop: directory, mode: "first" })
const found = yield* fs
.up({ targets: [...markers.targets()], start: candidate.directory, stop: directory, mode: "first" })
.pipe(Effect.orElseSucceed(() => []))
if (!markers[0]) return false
return (yield* fs.resolve(path.dirname(markers[0]))) === directory
if (!found[0]) return false
return (yield* fs.resolve(path.dirname(found[0]))) === directory
}),
)
yield* bus.publish(
@@ -305,15 +312,16 @@ const layer = Layer.effect(
const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
const directory = AbsolutePath.make(yield* fs.resolve(input))
const marker = yield* fs.up({ targets: [".git", ".hg"], start: directory, mode: "first" }).pipe(
const marker = yield* markers.discover(directory)
const native = yield* fs.up({ targets: [".git", ".hg"], start: directory, mode: "first" }).pipe(
Effect.map((matches) => matches[0]),
Effect.orElseSucceed(() => undefined),
)
const repo =
marker && path.basename(marker) === ".git"
? yield* git.repo.discover(AbsolutePath.make(path.dirname(marker)))
native && path.basename(native) === ".git"
? yield* git.repo.discover(AbsolutePath.make(path.dirname(native)))
: undefined
if (repo) {
if (repo && (!marker || FSUtil.contains(marker.directory, repo.worktree))) {
const previous = yield* cached(repo.commonDirectory)
const id = (yield* remote(repo)) ?? previous ?? (yield* rootCommit(repo))
const canonical =
@@ -329,11 +337,30 @@ const layer = Layer.effect(
directory: repo.worktree,
canonical,
vcs: { type: "git" as const, store: repo.commonDirectory },
...(marker?.directory === repo.worktree && marker.type !== "git" ? { vcsBackend: marker.type } : {}),
})
}
const hg = native && path.basename(native) === ".hg" ? yield* hgDiscover(AbsolutePath.make(native)) : undefined
if (hg && (!marker || FSUtil.contains(marker.directory, hg.directory))) {
return yield* persist({
...hg,
canonical: hg.directory,
...(marker?.directory === hg.directory && marker.type !== "hg" ? { vcsBackend: marker.type } : {}),
})
}
if (marker) {
const previous = yield* cached(marker.marker)
return yield* persist({
previous,
id: previous ?? ID.make(Hash.fast(`vcs-repository:${marker.type}:${marker.marker}`)),
directory: marker.directory,
canonical: marker.directory,
vcs: { type: marker.type, store: marker.marker },
})
}
const hg = marker && path.basename(marker) === ".hg" ? yield* hgDiscover(AbsolutePath.make(marker)) : undefined
if (hg) return yield* persist({ ...hg, canonical: hg.directory })
return yield* persist({
id: ID.make(Hash.fast(`directory:${directory}`)),
directory,
@@ -349,5 +376,5 @@ const layer = Layer.effect(
export const node = makeGlobalNode({
service: Service,
layer: layer,
deps: [Bus.node, Database.node, FSUtil.node, Git.node, AppProcess.node],
deps: [Bus.node, Database.node, FSUtil.node, Git.node, ProjectMarkers.node, AppProcess.node],
})
+176
View File
@@ -0,0 +1,176 @@
export * as ProjectMarkers from "./markers.js"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Global } from "@opencode-ai/util/global"
import { Npm } from "@opencode-ai/util/npm"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { Context, Effect, Layer, Option } from "effect"
import { parse, type ParseError } from "jsonc-parser"
import path from "path"
import { fileURLToPath } from "url"
import type { ConfigPluginSource } from "../config/plugin/source.js"
import type { Versioned } from "../plugin.js"
import { PluginModule } from "../plugin/module.js"
import { PluginSourceDirectory } from "../plugin/source-directory.js"
import { SdkPlugins } from "../plugin/sdk.js"
import { AbsolutePath } from "../schema.js"
export interface Match {
readonly type: string
readonly directory: AbsolutePath
readonly marker: AbsolutePath
}
export interface Interface {
readonly discover: (directory: AbsolutePath) => Effect.Effect<Match | undefined>
readonly targets: () => readonly string[]
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectMarkers") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const npm = yield* Npm.Service
const sdk = yield* SdkPlugins.Service
const known = new Set([".git", ".hg"])
const loaded = new Map<string, Versioned | undefined>()
const discover = Effect.fn("ProjectMarkers.discover")(function* (directory: AbsolutePath) {
const found = yield* fs
.up({ targets: [".opencode", "opencode.json", "opencode.jsonc"], start: directory })
.pipe(Effect.orElseSucceed(() => []))
const roots = [global.config, ...found.filter((value) => path.basename(value) === ".opencode").toReversed()]
const files = [
...["opencode.json", "opencode.jsonc"].map((name) => path.join(global.config, name)),
...found.filter((value) => path.basename(value) !== ".opencode").toReversed(),
...roots.slice(1).flatMap((root) => ["opencode.json", "opencode.jsonc"].map((name) => path.join(root, name))),
]
const automatic = yield* Effect.forEach(roots, (root) => PluginSourceDirectory.discover(fs, root)).pipe(
Effect.map((entries) => entries.flat()),
)
const configured = yield* Effect.forEach([...new Set(files)], (file) => read(fs, file)).pipe(
Effect.map((entries) => entries.flat()),
)
const operations = yield* Effect.forEach(
[
...automatic.map((target): ConfigPluginSource.Operation => ({ type: "add", target, options: {} })),
...configured,
],
(operation) => {
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Effect.succeed(operation)
return fs.stat(operation.target).pipe(
Effect.map((info) => ({
...operation,
mtime: Option.getOrElse(info.mtime, () => new Date(0)).getTime(),
})),
Effect.orElseSucceed(() => operation),
)
},
)
const declarations = new Map<string, { readonly id: string; readonly markers: readonly string[] }>()
for (const plugin of sdk.all()) {
if (!plugin.vcs) continue
declarations.set(plugin.id, { id: plugin.vcs.id ?? plugin.id, markers: plugin.vcs.markers })
}
for (const operation of operations) {
if (operation.type === "remove") {
for (const id of declarations.keys()) {
if (
operation.target === "*" ||
(operation.target.endsWith(".*") ? id.startsWith(operation.target.slice(0, -1)) : operation.target === id)
) {
declarations.delete(id)
}
}
continue
}
if (operation.target === "*" || operation.target.endsWith(".*") || operation.target.startsWith("opencode."))
continue
const key = JSON.stringify(operation)
const plugin = loaded.has(key)
? loaded.get(key)
: yield* PluginModule.load(operation).pipe(
Effect.provideService(Npm.Service, npm),
Effect.catchCause((cause) =>
Effect.logDebug("failed to discover plugin repository markers", {
target: operation.target,
cause,
}).pipe(Effect.as(undefined)),
),
Effect.tap((value) => Effect.sync(() => loaded.set(key, value))),
)
if (!plugin?.vcs) continue
declarations.set(plugin.id, { id: plugin.vcs.id ?? plugin.id, markers: plugin.vcs.markers })
}
const markers = new Map<string, string>()
for (const declaration of declarations.values()) {
if (!/^[a-z][a-z0-9._-]*$/.test(declaration.id)) continue
for (const marker of declaration.markers) {
if (!marker || marker === "." || marker === ".." || /[\\/]/.test(marker)) continue
known.add(marker)
markers.set(marker, declaration.id)
}
}
if (!markers.size) return undefined
const marker = yield* fs.up({ targets: [...markers.keys()], start: directory, mode: "first" }).pipe(
Effect.map((entries) => entries[0]),
Effect.orElseSucceed(() => undefined),
)
if (!marker) return undefined
const type = markers.get(path.basename(marker))
if (!type) return undefined
return {
type,
directory: AbsolutePath.make(path.dirname(marker)),
marker: AbsolutePath.make(marker),
} satisfies Match
})
return Service.of({ discover, targets: () => [...known] })
}),
)
function read(fs: FSUtil.Interface, file: string): Effect.Effect<ConfigPluginSource.Operation[]> {
return Effect.gen(function* () {
const source = yield* fs.readFileStringSafe(file).pipe(Effect.orElseSucceed(() => undefined))
if (!source) return []
const errors: ParseError[] = []
const document: unknown = parse(source, errors, { allowTrailingComma: true })
if (errors.length || typeof document !== "object" || document === null || !("plugins" in document)) return []
if (!Array.isArray(document.plugins)) return []
return document.plugins.flatMap<ConfigPluginSource.Operation>((entry) => {
if (typeof entry === "string" && entry.startsWith("-")) {
return [{ type: "remove", target: entry.slice(1) }]
}
if (
typeof entry !== "string" &&
(typeof entry !== "object" || entry === null || !("package" in entry) || typeof entry.package !== "string")
) {
return []
}
const target = typeof entry === "string" ? entry : entry.package
const options =
typeof entry !== "string" && "options" in entry && typeof entry.options === "object" && entry.options !== null
? Object.fromEntries(Object.entries(entry.options))
: {}
if (target.startsWith("file://")) return [{ type: "add", target: fileURLToPath(target), options }]
if (target.startsWith("./") || target.startsWith("../")) {
return [{ type: "add", target: path.resolve(path.dirname(file), target), options }]
}
return [{ type: "add", target, options }]
})
})
}
export const node = makeGlobalNode({
service: Service,
layer,
deps: [FSUtil.node, Global.node, Npm.node, SdkPlugins.node],
})
+4 -10
View File
@@ -18,14 +18,8 @@ export type UpdateInput = typeof UpdateInput.Type
export const Event = Project.Event
export const Vcs = Schema.Union([
Schema.Struct({
type: Schema.Literal("git"),
store: AbsolutePath,
}),
Schema.Struct({
type: Schema.Literal("hg"),
store: AbsolutePath,
}),
])
export const Vcs = Schema.Struct({
type: Project.Vcs,
store: AbsolutePath,
})
export type Vcs = typeof Vcs.Type
+1 -1
View File
@@ -12,7 +12,7 @@ type Transaction = Parameters<Parameters<DatabaseClient["transaction"]>[0]>[0]
export const ProjectTable = sqliteTable("project", {
id: text().$type<ProjectSchema.ID>().primaryKey(),
worktree: absoluteColumn().notNull(),
vcs: text().$type<"git" | "hg">(),
vcs: text().$type<ProjectSchema.Vcs["type"]>(),
name: text(),
icon_url: text(),
icon_url_override: text(),
+1 -1
View File
@@ -73,7 +73,7 @@ const layer = Layer.effect(
})
const selected = () => {
const value = state.get()
const id = value.selection ?? vcs?.type
const id = value.selection ?? location.vcsBackend ?? vcs?.type
return id ? value.providers.get(id) : undefined
}
const protect = <A>(provider: VcsDefinition, operation: string, effect: Effect.Effect<A, unknown>, fallback: A) =>
+100
View File
@@ -170,6 +170,106 @@ describe("Project.resolve", () => {
}),
)
it.live("discovers repository markers from automatically loaded plugins", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, ".opencode", "plugins"), { recursive: true })
await fs.mkdir(path.join(tmp.path, ".svn"))
await fs.mkdir(path.join(tmp.path, "nested", "directory"), { recursive: true })
await Bun.write(
path.join(tmp.path, ".opencode", "plugins", "svn.ts"),
'export default { id: "svn", vcs: { markers: [".svn"] }, setup() {} }',
)
})
const project = yield* Project.Service
const result = yield* project.resolve(abs(path.join(tmp.path, "nested", "directory")))
expect(result.directory).toBe(abs(tmp.path))
expect(result.canonical).toBe(abs(tmp.path))
expect(result.vcs).toEqual({ type: "svn", store: abs(path.join(tmp.path, ".svn")) })
expect(result.id).not.toBe(Project.ID.global)
expect((yield* project.list()).find((item) => item.id === result.id)?.vcs).toBe("svn")
}),
)
it.live("discovers repository markers from configured plugin files", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(async () => {
await fs.mkdir(path.join(tmp.path, ".pijul"))
await Bun.write(path.join(tmp.path, "opencode.jsonc"), '{ "plugins": ["./pijul.ts"] }')
await Bun.write(
path.join(tmp.path, "pijul.ts"),
'export default { id: "custom.pijul", vcs: { id: "pijul", markers: [".pijul"] }, setup() {} }',
)
})
const project = yield* Project.Service
const result = yield* project.resolve(abs(tmp.path))
expect(result.directory).toBe(abs(tmp.path))
expect(result.vcs?.type).toBe("pijul")
}),
)
it.live("prefers a nested plugin repository over its parent git repository", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
const nested = path.join(tmp.path, "nested")
yield* Effect.promise(async () => {
await initRepo(tmp.path, { commit: true })
await fs.mkdir(path.join(tmp.path, ".opencode", "plugins"), { recursive: true })
await fs.mkdir(path.join(nested, ".svn"), { recursive: true })
await Bun.write(
path.join(tmp.path, ".opencode", "plugins", "svn.ts"),
'export default { id: "svn", vcs: { markers: [".svn"] }, setup() {} }',
)
})
const project = yield* Project.Service
const result = yield* project.resolve(abs(nested))
expect(result.directory).toBe(abs(nested))
expect(result.vcs?.type).toBe("svn")
}),
)
it.live("preserves git identity when a plugin marker shares its repository", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
)
yield* Effect.promise(async () => {
await initRepo(tmp.path, { commit: true })
await fs.mkdir(path.join(tmp.path, ".opencode", "plugins"), { recursive: true })
await fs.mkdir(path.join(tmp.path, ".jj"))
await Bun.write(
path.join(tmp.path, ".opencode", "plugins", "jj.ts"),
'export default { id: "jj", vcs: { markers: [".jj"] }, setup() {} }',
)
})
const project = yield* Project.Service
const result = yield* project.resolve(abs(tmp.path))
expect(result.id).toBe(Project.ID.make(yield* Effect.promise(() => rootCommit(tmp.path))))
expect(result.vcs?.type).toBe("git")
expect(result.vcsBackend).toBe("jj")
}),
)
it.live("repository markers override markerless directory projects", () =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireRelease(
+2
View File
@@ -1,6 +1,7 @@
import type { GenerateApi, PluginApi } from "@opencode-ai/client/effect/api"
import type { Effect, Scope } from "effect"
import type { PluginOptions } from "../options.js"
import type { VcsDiscovery } from "../vcs.js"
import type { App } from "../app.js"
import type { AgentDomain } from "./agent.js"
import type { AISDKDomain } from "./aisdk.js"
@@ -45,6 +46,7 @@ export interface Context {
export interface Plugin<R = Scope.Scope> {
readonly id: string
readonly tui?: boolean
readonly vcs?: VcsDiscovery
readonly effect: (context: Context) => Effect.Effect<void, never, R>
}
+1
View File
@@ -69,6 +69,7 @@ export function fromPromise(plugin: Plugin) {
return define({
id: plugin.id,
tui: plugin.tui,
vcs: plugin.vcs,
effect: (host) =>
Effect.gen(function* () {
const [{ ClientApi }, { OpenCodeEvent }] = yield* Effect.promise(() =>
+2
View File
@@ -1,5 +1,6 @@
import type { GenerateApi, PluginApi } from "@opencode-ai/client/promise/api"
import type { PluginOptions } from "../options.js"
import type { VcsDiscovery } from "../vcs.js"
import type { App } from "../app.js"
import type { AgentDomain } from "./agent.js"
import type { AISDKDomain } from "./aisdk.js"
@@ -46,6 +47,7 @@ export type Cleanup = () => Promise<void> | void
export interface Plugin {
readonly id: string
readonly tui?: boolean
readonly vcs?: VcsDiscovery
readonly setup: (context: Context) => Promise<Cleanup | void> | Cleanup | void
}
+4
View File
@@ -0,0 +1,4 @@
export interface VcsDiscovery {
readonly id?: string
readonly markers: readonly string[]
}
@@ -1,4 +1,5 @@
import { expect, test } from "bun:test"
import { Effect } from "effect"
import { Agent } from "@opencode-ai/schema/agent"
import { Command } from "@opencode-ai/schema/command"
import { Connection } from "@opencode-ai/schema/connection"
@@ -49,6 +50,13 @@ test.each([
])
})
test.each([
["effect", Plugin.Plugin.define({ id: "svn", vcs: { markers: [".svn"] }, effect: () => Effect.void })],
["promise", PromisePlugin.Plugin.define({ id: "svn", vcs: { markers: [".svn"] }, setup() {} })],
])("%s plugin definitions retain repository markers", (_name, plugin) => {
expect(plugin.vcs).toEqual({ markers: [".svn"] })
})
test("tui entrypoint exposes the plugin definition", () => {
const plugin = TuiPlugin.Plugin.define({ id: "demo", setup() {} })
expect(plugin.id).toBe("demo")
+3 -1
View File
@@ -8,7 +8,9 @@ import { ProjectID } from "./project-id.js"
export const ID = ProjectID
export type ID = typeof ID.Type
export const Vcs = Schema.Literals(["git", "hg"]).annotate({ identifier: "Project.Vcs" })
export const Vcs = Schema.String.check(Schema.isPattern(/^[a-z][a-z0-9._-]*$/)).annotate({
identifier: "Project.Vcs",
})
export const Current = Schema.Struct({
id: ID,
directory: AbsolutePath,