From d70099b0596b60450ca3c0d45b01816eca25fb54 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 20 Mar 2026 14:37:12 -0400 Subject: [PATCH] fix: apply Layer.fresh at instance service definition site (#18418) --- packages/opencode/src/effect/instances.ts | 38 +- packages/opencode/src/file/index.ts | 689 +----------------- packages/opencode/src/file/service.ts | 674 +++++++++++++++++ packages/opencode/src/file/time-service.ts | 93 +++ packages/opencode/src/file/time.ts | 100 +-- packages/opencode/src/file/watcher.ts | 2 +- packages/opencode/src/format/index.ts | 155 +--- packages/opencode/src/format/service.ts | 152 ++++ packages/opencode/src/permission/index.ts | 300 +------- packages/opencode/src/permission/service.ts | 282 +++++++ packages/opencode/src/project/vcs.ts | 2 +- .../opencode/src/provider/auth-service.ts | 215 ++++++ packages/opencode/src/provider/auth.ts | 226 +----- packages/opencode/src/question/index.ts | 194 +---- packages/opencode/src/question/service.ts | 172 +++++ packages/opencode/src/skill/service.ts | 238 ++++++ packages/opencode/src/skill/skill.ts | 250 +------ packages/opencode/src/snapshot/index.ts | 341 +-------- packages/opencode/src/snapshot/service.ts | 320 ++++++++ packages/opencode/test/effect/runtime.test.ts | 128 ++++ packages/opencode/test/fixture/instance.ts | 2 +- 21 files changed, 2434 insertions(+), 2139 deletions(-) create mode 100644 packages/opencode/src/file/service.ts create mode 100644 packages/opencode/src/file/time-service.ts create mode 100644 packages/opencode/src/format/service.ts create mode 100644 packages/opencode/src/permission/service.ts create mode 100644 packages/opencode/src/provider/auth-service.ts create mode 100644 packages/opencode/src/question/service.ts create mode 100644 packages/opencode/src/skill/service.ts create mode 100644 packages/opencode/src/snapshot/service.ts create mode 100644 packages/opencode/test/effect/runtime.test.ts diff --git a/packages/opencode/src/effect/instances.ts b/packages/opencode/src/effect/instances.ts index c05458d5d..6fcfddb24 100644 --- a/packages/opencode/src/effect/instances.ts +++ b/packages/opencode/src/effect/instances.ts @@ -1,15 +1,15 @@ import { Effect, Layer, LayerMap, ServiceMap } from "effect" -import { File } from "@/file" -import { FileTime } from "@/file/time" +import { File } from "@/file/service" +import { FileTime } from "@/file/time-service" import { FileWatcher } from "@/file/watcher" -import { Format } from "@/format" -import { PermissionNext } from "@/permission" +import { Format } from "@/format/service" +import { Permission } from "@/permission/service" import { Instance } from "@/project/instance" import { Vcs } from "@/project/vcs" -import { ProviderAuth } from "@/provider/auth" -import { Question } from "@/question" -import { Skill } from "@/skill/skill" -import { Snapshot } from "@/snapshot" +import { ProviderAuth } from "@/provider/auth-service" +import { Question } from "@/question/service" +import { Skill } from "@/skill/service" +import { Snapshot } from "@/snapshot/service" import { InstanceContext } from "./instance-context" import { registerDisposer } from "./instance-registry" @@ -17,7 +17,7 @@ export { InstanceContext } from "./instance-context" export type InstanceServices = | Question.Service - | PermissionNext.Service + | Permission.Service | ProviderAuth.Service | FileWatcher.Service | Vcs.Service @@ -36,16 +36,16 @@ export type InstanceServices = function lookup(_key: string) { const ctx = Layer.sync(InstanceContext, () => InstanceContext.of(Instance.current)) return Layer.mergeAll( - Layer.fresh(Question.layer), - Layer.fresh(PermissionNext.layer), - Layer.fresh(ProviderAuth.defaultLayer), - Layer.fresh(FileWatcher.layer).pipe(Layer.orDie), - Layer.fresh(Vcs.layer), - Layer.fresh(FileTime.layer).pipe(Layer.orDie), - Layer.fresh(Format.layer), - Layer.fresh(File.layer), - Layer.fresh(Skill.defaultLayer), - Layer.fresh(Snapshot.defaultLayer), + Question.layer, + Permission.layer, + ProviderAuth.defaultLayer, + FileWatcher.layer, + Vcs.layer, + FileTime.layer, + Format.layer, + File.layer, + Skill.defaultLayer, + Snapshot.defaultLayer, ).pipe(Layer.provide(ctx)) } diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 6e9b91727..35a5b5e20 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -1,695 +1,40 @@ -import { BusEvent } from "@/bus/bus-event" -import { InstanceContext } from "@/effect/instance-context" import { runPromiseInstance } from "@/effect/runtime" -import { git } from "@/util/git" -import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect" -import { formatPatch, structuredPatch } from "diff" -import fs from "fs" -import fuzzysort from "fuzzysort" -import ignore from "ignore" -import path from "path" -import z from "zod" -import { Global } from "../global" -import { Instance } from "../project/instance" -import { Filesystem } from "../util/filesystem" -import { Log } from "../util/log" -import { Protected } from "./protected" -import { Ripgrep } from "./ripgrep" +import { File as S } from "./service" export namespace File { - export const Info = z - .object({ - path: z.string(), - added: z.number().int(), - removed: z.number().int(), - status: z.enum(["added", "deleted", "modified"]), - }) - .meta({ - ref: "File", - }) + export const Info = S.Info + export type Info = S.Info - export type Info = z.infer + export const Node = S.Node + export type Node = S.Node - export const Node = z - .object({ - name: z.string(), - path: z.string(), - absolute: z.string(), - type: z.enum(["file", "directory"]), - ignored: z.boolean(), - }) - .meta({ - ref: "FileNode", - }) - export type Node = z.infer + export const Content = S.Content + export type Content = S.Content - export const Content = z - .object({ - type: z.enum(["text", "binary"]), - content: z.string(), - diff: z.string().optional(), - patch: z - .object({ - oldFileName: z.string(), - newFileName: z.string(), - oldHeader: z.string().optional(), - newHeader: z.string().optional(), - hunks: z.array( - z.object({ - oldStart: z.number(), - oldLines: z.number(), - newStart: z.number(), - newLines: z.number(), - lines: z.array(z.string()), - }), - ), - index: z.string().optional(), - }) - .optional(), - encoding: z.literal("base64").optional(), - mimeType: z.string().optional(), - }) - .meta({ - ref: "FileContent", - }) - export type Content = z.infer + export const Event = S.Event - export const Event = { - Edited: BusEvent.define( - "file.edited", - z.object({ - file: z.string(), - }), - ), - } + export type Interface = S.Interface + + export const Service = S.Service + export const layer = S.layer export function init() { - return runPromiseInstance(Service.use((svc) => svc.init())) + return runPromiseInstance(S.Service.use((svc) => svc.init())) } export async function status() { - return runPromiseInstance(Service.use((svc) => svc.status())) + return runPromiseInstance(S.Service.use((svc) => svc.status())) } export async function read(file: string): Promise { - return runPromiseInstance(Service.use((svc) => svc.read(file))) + return runPromiseInstance(S.Service.use((svc) => svc.read(file))) } export async function list(dir?: string) { - return runPromiseInstance(Service.use((svc) => svc.list(dir))) + return runPromiseInstance(S.Service.use((svc) => svc.list(dir))) } export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) { - return runPromiseInstance(Service.use((svc) => svc.search(input))) + return runPromiseInstance(S.Service.use((svc) => svc.search(input))) } - - const log = Log.create({ service: "file" }) - - const binary = new Set([ - "exe", - "dll", - "pdb", - "bin", - "so", - "dylib", - "o", - "a", - "lib", - "wav", - "mp3", - "ogg", - "oga", - "ogv", - "ogx", - "flac", - "aac", - "wma", - "m4a", - "weba", - "mp4", - "avi", - "mov", - "wmv", - "flv", - "webm", - "mkv", - "zip", - "tar", - "gz", - "gzip", - "bz", - "bz2", - "bzip", - "bzip2", - "7z", - "rar", - "xz", - "lz", - "z", - "pdf", - "doc", - "docx", - "ppt", - "pptx", - "xls", - "xlsx", - "dmg", - "iso", - "img", - "vmdk", - "ttf", - "otf", - "woff", - "woff2", - "eot", - "sqlite", - "db", - "mdb", - "apk", - "ipa", - "aab", - "xapk", - "app", - "pkg", - "deb", - "rpm", - "snap", - "flatpak", - "appimage", - "msi", - "msp", - "jar", - "war", - "ear", - "class", - "kotlin_module", - "dex", - "vdex", - "odex", - "oat", - "art", - "wasm", - "wat", - "bc", - "ll", - "s", - "ko", - "sys", - "drv", - "efi", - "rom", - "com", - "cmd", - "ps1", - "sh", - "bash", - "zsh", - "fish", - ]) - - const image = new Set([ - "png", - "jpg", - "jpeg", - "gif", - "bmp", - "webp", - "ico", - "tif", - "tiff", - "svg", - "svgz", - "avif", - "apng", - "jxl", - "heic", - "heif", - "raw", - "cr2", - "nef", - "arw", - "dng", - "orf", - "raf", - "pef", - "x3f", - ]) - - const text = new Set([ - "ts", - "tsx", - "mts", - "cts", - "mtsx", - "ctsx", - "js", - "jsx", - "mjs", - "cjs", - "sh", - "bash", - "zsh", - "fish", - "ps1", - "psm1", - "cmd", - "bat", - "json", - "jsonc", - "json5", - "yaml", - "yml", - "toml", - "md", - "mdx", - "txt", - "xml", - "html", - "htm", - "css", - "scss", - "sass", - "less", - "graphql", - "gql", - "sql", - "ini", - "cfg", - "conf", - "env", - ]) - - const textName = new Set([ - "dockerfile", - "makefile", - ".gitignore", - ".gitattributes", - ".editorconfig", - ".npmrc", - ".nvmrc", - ".prettierrc", - ".eslintrc", - ]) - - const mime: Record = { - png: "image/png", - jpg: "image/jpeg", - jpeg: "image/jpeg", - gif: "image/gif", - bmp: "image/bmp", - webp: "image/webp", - ico: "image/x-icon", - tif: "image/tiff", - tiff: "image/tiff", - svg: "image/svg+xml", - svgz: "image/svg+xml", - avif: "image/avif", - apng: "image/apng", - jxl: "image/jxl", - heic: "image/heic", - heif: "image/heif", - } - - type Entry = { files: string[]; dirs: string[] } - - const ext = (file: string) => path.extname(file).toLowerCase().slice(1) - const name = (file: string) => path.basename(file).toLowerCase() - const isImageByExtension = (file: string) => image.has(ext(file)) - const isTextByExtension = (file: string) => text.has(ext(file)) - const isTextByName = (file: string) => textName.has(name(file)) - const isBinaryByExtension = (file: string) => binary.has(ext(file)) - const isImage = (mimeType: string) => mimeType.startsWith("image/") - const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file) - - function shouldEncode(mimeType: string) { - const type = mimeType.toLowerCase() - log.info("shouldEncode", { type }) - if (!type) return false - if (type.startsWith("text/")) return false - if (type.includes("charset=")) return false - const top = type.split("/", 2)[0] - return ["image", "audio", "video", "font", "model", "multipart"].includes(top) - } - - const hidden = (item: string) => { - const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "") - return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1) - } - - const sortHiddenLast = (items: string[], prefer: boolean) => { - if (prefer) return items - const visible: string[] = [] - const hiddenItems: string[] = [] - for (const item of items) { - if (hidden(item)) hiddenItems.push(item) - else visible.push(item) - } - return [...visible, ...hiddenItems] - } - - export interface Interface { - readonly init: () => Effect.Effect - readonly status: () => Effect.Effect - readonly read: (file: string) => Effect.Effect - readonly list: (dir?: string) => Effect.Effect - readonly search: (input: { - query: string - limit?: number - dirs?: boolean - type?: "file" | "directory" - }) => Effect.Effect - } - - export class Service extends ServiceMap.Service()("@opencode/File") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const instance = yield* InstanceContext - let cache: Entry = { files: [], dirs: [] } - const isGlobalHome = instance.directory === Global.Path.home && instance.project.id === "global" - - const scan = Effect.fn("File.scan")(function* () { - if (instance.directory === path.parse(instance.directory).root) return - const next: Entry = { files: [], dirs: [] } - - yield* Effect.promise(async () => { - if (isGlobalHome) { - const dirs = new Set() - const protectedNames = Protected.names() - const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"]) - const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name) - const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name) - const top = await fs.promises - .readdir(instance.directory, { withFileTypes: true }) - .catch(() => [] as fs.Dirent[]) - - for (const entry of top) { - if (!entry.isDirectory()) continue - if (shouldIgnoreName(entry.name)) continue - dirs.add(entry.name + "/") - - const base = path.join(instance.directory, entry.name) - const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[]) - for (const child of children) { - if (!child.isDirectory()) continue - if (shouldIgnoreNested(child.name)) continue - dirs.add(entry.name + "/" + child.name + "/") - } - } - - next.dirs = Array.from(dirs).toSorted() - } else { - const seen = new Set() - for await (const file of Ripgrep.files({ cwd: instance.directory })) { - next.files.push(file) - let current = file - while (true) { - const dir = path.dirname(current) - if (dir === ".") break - if (dir === current) break - current = dir - if (seen.has(dir)) continue - seen.add(dir) - next.dirs.push(dir + "/") - } - } - } - }) - - cache = next - }) - - const getFiles = () => cache - - const scope = yield* Scope.Scope - let fiber: Fiber.Fiber | undefined - - const init = Effect.fn("File.init")(function* () { - if (!fiber) { - fiber = yield* scan().pipe( - Effect.catchCause(() => Effect.void), - Effect.forkIn(scope), - ) - } - yield* Fiber.join(fiber) - }) - - const status = Effect.fn("File.status")(function* () { - if (instance.project.vcs !== "git") return [] - - return yield* Effect.promise(async () => { - const diffOutput = ( - await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], { - cwd: instance.directory, - }) - ).text() - - const changed: File.Info[] = [] - - if (diffOutput.trim()) { - for (const line of diffOutput.trim().split("\n")) { - const [added, removed, file] = line.split("\t") - changed.push({ - path: file, - added: added === "-" ? 0 : parseInt(added, 10), - removed: removed === "-" ? 0 : parseInt(removed, 10), - status: "modified", - }) - } - } - - const untrackedOutput = ( - await git( - [ - "-c", - "core.fsmonitor=false", - "-c", - "core.quotepath=false", - "ls-files", - "--others", - "--exclude-standard", - ], - { - cwd: instance.directory, - }, - ) - ).text() - - if (untrackedOutput.trim()) { - for (const file of untrackedOutput.trim().split("\n")) { - try { - const content = await Filesystem.readText(path.join(instance.directory, file)) - changed.push({ - path: file, - added: content.split("\n").length, - removed: 0, - status: "added", - }) - } catch { - continue - } - } - } - - const deletedOutput = ( - await git( - [ - "-c", - "core.fsmonitor=false", - "-c", - "core.quotepath=false", - "diff", - "--name-only", - "--diff-filter=D", - "HEAD", - ], - { - cwd: instance.directory, - }, - ) - ).text() - - if (deletedOutput.trim()) { - for (const file of deletedOutput.trim().split("\n")) { - changed.push({ - path: file, - added: 0, - removed: 0, - status: "deleted", - }) - } - } - - return changed.map((item) => { - const full = path.isAbsolute(item.path) ? item.path : path.join(instance.directory, item.path) - return { - ...item, - path: path.relative(instance.directory, full), - } - }) - }) - }) - - const read = Effect.fn("File.read")(function* (file: string) { - return yield* Effect.promise(async (): Promise => { - using _ = log.time("read", { file }) - const full = path.join(instance.directory, file) - - if (!Instance.containsPath(full)) { - throw new Error("Access denied: path escapes project directory") - } - - if (isImageByExtension(file)) { - if (await Filesystem.exists(full)) { - const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([])) - return { - type: "text", - content: buffer.toString("base64"), - mimeType: getImageMimeType(file), - encoding: "base64", - } - } - return { type: "text", content: "" } - } - - const knownText = isTextByExtension(file) || isTextByName(file) - - if (isBinaryByExtension(file) && !knownText) { - return { type: "binary", content: "" } - } - - if (!(await Filesystem.exists(full))) { - return { type: "text", content: "" } - } - - const mimeType = Filesystem.mimeType(full) - const encode = knownText ? false : shouldEncode(mimeType) - - if (encode && !isImage(mimeType)) { - return { type: "binary", content: "", mimeType } - } - - if (encode) { - const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([])) - return { - type: "text", - content: buffer.toString("base64"), - mimeType, - encoding: "base64", - } - } - - const content = (await Filesystem.readText(full).catch(() => "")).trim() - - if (instance.project.vcs === "git") { - let diff = ( - await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: instance.directory }) - ).text() - if (!diff.trim()) { - diff = ( - await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { - cwd: instance.directory, - }) - ).text() - } - if (diff.trim()) { - const original = (await git(["show", `HEAD:${file}`], { cwd: instance.directory })).text() - const patch = structuredPatch(file, file, original, content, "old", "new", { - context: Infinity, - ignoreWhitespace: true, - }) - return { - type: "text", - content, - patch, - diff: formatPatch(patch), - } - } - } - - return { type: "text", content } - }) - }) - - const list = Effect.fn("File.list")(function* (dir?: string) { - return yield* Effect.promise(async () => { - const exclude = [".git", ".DS_Store"] - let ignored = (_: string) => false - if (instance.project.vcs === "git") { - const ig = ignore() - const gitignore = path.join(instance.project.worktree, ".gitignore") - if (await Filesystem.exists(gitignore)) { - ig.add(await Filesystem.readText(gitignore)) - } - const ignoreFile = path.join(instance.project.worktree, ".ignore") - if (await Filesystem.exists(ignoreFile)) { - ig.add(await Filesystem.readText(ignoreFile)) - } - ignored = ig.ignores.bind(ig) - } - - const resolved = dir ? path.join(instance.directory, dir) : instance.directory - if (!Instance.containsPath(resolved)) { - throw new Error("Access denied: path escapes project directory") - } - - const nodes: File.Node[] = [] - for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) { - if (exclude.includes(entry.name)) continue - const absolute = path.join(resolved, entry.name) - const file = path.relative(instance.directory, absolute) - const type = entry.isDirectory() ? "directory" : "file" - nodes.push({ - name: entry.name, - path: file, - absolute, - type, - ignored: ignored(type === "directory" ? file + "/" : file), - }) - } - - return nodes.sort((a, b) => { - if (a.type !== b.type) return a.type === "directory" ? -1 : 1 - return a.name.localeCompare(b.name) - }) - }) - }) - - const search = Effect.fn("File.search")(function* (input: { - query: string - limit?: number - dirs?: boolean - type?: "file" | "directory" - }) { - return yield* Effect.promise(async () => { - const query = input.query.trim() - const limit = input.limit ?? 100 - const kind = input.type ?? (input.dirs === false ? "file" : "all") - log.info("search", { query, kind }) - - const result = getFiles() - const preferHidden = query.startsWith(".") || query.includes("/.") - - if (!query) { - if (kind === "file") return result.files.slice(0, limit) - return sortHiddenLast(result.dirs.toSorted(), preferHidden).slice(0, limit) - } - - const items = - kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs] - - const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit - const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target) - const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted - - log.info("search", { query, kind, results: output.length }) - return output - }) - }) - - log.info("init") - return Service.of({ init, status, read, list, search }) - }), - ) } diff --git a/packages/opencode/src/file/service.ts b/packages/opencode/src/file/service.ts new file mode 100644 index 000000000..d4f6b347f --- /dev/null +++ b/packages/opencode/src/file/service.ts @@ -0,0 +1,674 @@ +import { BusEvent } from "@/bus/bus-event" +import { InstanceContext } from "@/effect/instance-context" +import { git } from "@/util/git" +import { Effect, Fiber, Layer, Scope, ServiceMap } from "effect" +import { formatPatch, structuredPatch } from "diff" +import fs from "fs" +import fuzzysort from "fuzzysort" +import ignore from "ignore" +import path from "path" +import z from "zod" +import { Global } from "../global" +import { Instance } from "../project/instance" +import { Filesystem } from "../util/filesystem" +import { Log } from "../util/log" +import { Protected } from "./protected" +import { Ripgrep } from "./ripgrep" + +export namespace File { + export const Info = z + .object({ + path: z.string(), + added: z.number().int(), + removed: z.number().int(), + status: z.enum(["added", "deleted", "modified"]), + }) + .meta({ + ref: "File", + }) + + export type Info = z.infer + + export const Node = z + .object({ + name: z.string(), + path: z.string(), + absolute: z.string(), + type: z.enum(["file", "directory"]), + ignored: z.boolean(), + }) + .meta({ + ref: "FileNode", + }) + export type Node = z.infer + + export const Content = z + .object({ + type: z.enum(["text", "binary"]), + content: z.string(), + diff: z.string().optional(), + patch: z + .object({ + oldFileName: z.string(), + newFileName: z.string(), + oldHeader: z.string().optional(), + newHeader: z.string().optional(), + hunks: z.array( + z.object({ + oldStart: z.number(), + oldLines: z.number(), + newStart: z.number(), + newLines: z.number(), + lines: z.array(z.string()), + }), + ), + index: z.string().optional(), + }) + .optional(), + encoding: z.literal("base64").optional(), + mimeType: z.string().optional(), + }) + .meta({ + ref: "FileContent", + }) + export type Content = z.infer + + export const Event = { + Edited: BusEvent.define( + "file.edited", + z.object({ + file: z.string(), + }), + ), + } + + const log = Log.create({ service: "file" }) + + const binary = new Set([ + "exe", + "dll", + "pdb", + "bin", + "so", + "dylib", + "o", + "a", + "lib", + "wav", + "mp3", + "ogg", + "oga", + "ogv", + "ogx", + "flac", + "aac", + "wma", + "m4a", + "weba", + "mp4", + "avi", + "mov", + "wmv", + "flv", + "webm", + "mkv", + "zip", + "tar", + "gz", + "gzip", + "bz", + "bz2", + "bzip", + "bzip2", + "7z", + "rar", + "xz", + "lz", + "z", + "pdf", + "doc", + "docx", + "ppt", + "pptx", + "xls", + "xlsx", + "dmg", + "iso", + "img", + "vmdk", + "ttf", + "otf", + "woff", + "woff2", + "eot", + "sqlite", + "db", + "mdb", + "apk", + "ipa", + "aab", + "xapk", + "app", + "pkg", + "deb", + "rpm", + "snap", + "flatpak", + "appimage", + "msi", + "msp", + "jar", + "war", + "ear", + "class", + "kotlin_module", + "dex", + "vdex", + "odex", + "oat", + "art", + "wasm", + "wat", + "bc", + "ll", + "s", + "ko", + "sys", + "drv", + "efi", + "rom", + "com", + "cmd", + "ps1", + "sh", + "bash", + "zsh", + "fish", + ]) + + const image = new Set([ + "png", + "jpg", + "jpeg", + "gif", + "bmp", + "webp", + "ico", + "tif", + "tiff", + "svg", + "svgz", + "avif", + "apng", + "jxl", + "heic", + "heif", + "raw", + "cr2", + "nef", + "arw", + "dng", + "orf", + "raf", + "pef", + "x3f", + ]) + + const text = new Set([ + "ts", + "tsx", + "mts", + "cts", + "mtsx", + "ctsx", + "js", + "jsx", + "mjs", + "cjs", + "sh", + "bash", + "zsh", + "fish", + "ps1", + "psm1", + "cmd", + "bat", + "json", + "jsonc", + "json5", + "yaml", + "yml", + "toml", + "md", + "mdx", + "txt", + "xml", + "html", + "htm", + "css", + "scss", + "sass", + "less", + "graphql", + "gql", + "sql", + "ini", + "cfg", + "conf", + "env", + ]) + + const textName = new Set([ + "dockerfile", + "makefile", + ".gitignore", + ".gitattributes", + ".editorconfig", + ".npmrc", + ".nvmrc", + ".prettierrc", + ".eslintrc", + ]) + + const mime: Record = { + png: "image/png", + jpg: "image/jpeg", + jpeg: "image/jpeg", + gif: "image/gif", + bmp: "image/bmp", + webp: "image/webp", + ico: "image/x-icon", + tif: "image/tiff", + tiff: "image/tiff", + svg: "image/svg+xml", + svgz: "image/svg+xml", + avif: "image/avif", + apng: "image/apng", + jxl: "image/jxl", + heic: "image/heic", + heif: "image/heif", + } + + type Entry = { files: string[]; dirs: string[] } + + const ext = (file: string) => path.extname(file).toLowerCase().slice(1) + const name = (file: string) => path.basename(file).toLowerCase() + const isImageByExtension = (file: string) => image.has(ext(file)) + const isTextByExtension = (file: string) => text.has(ext(file)) + const isTextByName = (file: string) => textName.has(name(file)) + const isBinaryByExtension = (file: string) => binary.has(ext(file)) + const isImage = (mimeType: string) => mimeType.startsWith("image/") + const getImageMimeType = (file: string) => mime[ext(file)] || "image/" + ext(file) + + function shouldEncode(mimeType: string) { + const type = mimeType.toLowerCase() + log.info("shouldEncode", { type }) + if (!type) return false + if (type.startsWith("text/")) return false + if (type.includes("charset=")) return false + const top = type.split("/", 2)[0] + return ["image", "audio", "video", "font", "model", "multipart"].includes(top) + } + + const hidden = (item: string) => { + const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "") + return normalized.split("/").some((part) => part.startsWith(".") && part.length > 1) + } + + const sortHiddenLast = (items: string[], prefer: boolean) => { + if (prefer) return items + const visible: string[] = [] + const hiddenItems: string[] = [] + for (const item of items) { + if (hidden(item)) hiddenItems.push(item) + else visible.push(item) + } + return [...visible, ...hiddenItems] + } + + export interface Interface { + readonly init: () => Effect.Effect + readonly status: () => Effect.Effect + readonly read: (file: string) => Effect.Effect + readonly list: (dir?: string) => Effect.Effect + readonly search: (input: { + query: string + limit?: number + dirs?: boolean + type?: "file" | "directory" + }) => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/File") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const instance = yield* InstanceContext + let cache: Entry = { files: [], dirs: [] } + const isGlobalHome = instance.directory === Global.Path.home && instance.project.id === "global" + + const scan = Effect.fn("File.scan")(function* () { + if (instance.directory === path.parse(instance.directory).root) return + const next: Entry = { files: [], dirs: [] } + + yield* Effect.promise(async () => { + if (isGlobalHome) { + const dirs = new Set() + const protectedNames = Protected.names() + const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"]) + const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name) + const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name) + const top = await fs.promises + .readdir(instance.directory, { withFileTypes: true }) + .catch(() => [] as fs.Dirent[]) + + for (const entry of top) { + if (!entry.isDirectory()) continue + if (shouldIgnoreName(entry.name)) continue + dirs.add(entry.name + "/") + + const base = path.join(instance.directory, entry.name) + const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[]) + for (const child of children) { + if (!child.isDirectory()) continue + if (shouldIgnoreNested(child.name)) continue + dirs.add(entry.name + "/" + child.name + "/") + } + } + + next.dirs = Array.from(dirs).toSorted() + } else { + const seen = new Set() + for await (const file of Ripgrep.files({ cwd: instance.directory })) { + next.files.push(file) + let current = file + while (true) { + const dir = path.dirname(current) + if (dir === ".") break + if (dir === current) break + current = dir + if (seen.has(dir)) continue + seen.add(dir) + next.dirs.push(dir + "/") + } + } + } + }) + + cache = next + }) + + const getFiles = () => cache + + const scope = yield* Scope.Scope + let fiber: Fiber.Fiber | undefined + + const init = Effect.fn("File.init")(function* () { + if (!fiber) { + fiber = yield* scan().pipe( + Effect.catchCause(() => Effect.void), + Effect.forkIn(scope), + ) + } + yield* Fiber.join(fiber) + }) + + const status = Effect.fn("File.status")(function* () { + if (instance.project.vcs !== "git") return [] + + return yield* Effect.promise(async () => { + const diffOutput = ( + await git(["-c", "core.fsmonitor=false", "-c", "core.quotepath=false", "diff", "--numstat", "HEAD"], { + cwd: instance.directory, + }) + ).text() + + const changed: File.Info[] = [] + + if (diffOutput.trim()) { + for (const line of diffOutput.trim().split("\n")) { + const [added, removed, file] = line.split("\t") + changed.push({ + path: file, + added: added === "-" ? 0 : parseInt(added, 10), + removed: removed === "-" ? 0 : parseInt(removed, 10), + status: "modified", + }) + } + } + + const untrackedOutput = ( + await git( + [ + "-c", + "core.fsmonitor=false", + "-c", + "core.quotepath=false", + "ls-files", + "--others", + "--exclude-standard", + ], + { + cwd: instance.directory, + }, + ) + ).text() + + if (untrackedOutput.trim()) { + for (const file of untrackedOutput.trim().split("\n")) { + try { + const content = await Filesystem.readText(path.join(instance.directory, file)) + changed.push({ + path: file, + added: content.split("\n").length, + removed: 0, + status: "added", + }) + } catch { + continue + } + } + } + + const deletedOutput = ( + await git( + [ + "-c", + "core.fsmonitor=false", + "-c", + "core.quotepath=false", + "diff", + "--name-only", + "--diff-filter=D", + "HEAD", + ], + { + cwd: instance.directory, + }, + ) + ).text() + + if (deletedOutput.trim()) { + for (const file of deletedOutput.trim().split("\n")) { + changed.push({ + path: file, + added: 0, + removed: 0, + status: "deleted", + }) + } + } + + return changed.map((item) => { + const full = path.isAbsolute(item.path) ? item.path : path.join(instance.directory, item.path) + return { + ...item, + path: path.relative(instance.directory, full), + } + }) + }) + }) + + const read = Effect.fn("File.read")(function* (file: string) { + return yield* Effect.promise(async (): Promise => { + using _ = log.time("read", { file }) + const full = path.join(instance.directory, file) + + if (!Instance.containsPath(full)) { + throw new Error("Access denied: path escapes project directory") + } + + if (isImageByExtension(file)) { + if (await Filesystem.exists(full)) { + const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([])) + return { + type: "text", + content: buffer.toString("base64"), + mimeType: getImageMimeType(file), + encoding: "base64", + } + } + return { type: "text", content: "" } + } + + const knownText = isTextByExtension(file) || isTextByName(file) + + if (isBinaryByExtension(file) && !knownText) { + return { type: "binary", content: "" } + } + + if (!(await Filesystem.exists(full))) { + return { type: "text", content: "" } + } + + const mimeType = Filesystem.mimeType(full) + const encode = knownText ? false : shouldEncode(mimeType) + + if (encode && !isImage(mimeType)) { + return { type: "binary", content: "", mimeType } + } + + if (encode) { + const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([])) + return { + type: "text", + content: buffer.toString("base64"), + mimeType, + encoding: "base64", + } + } + + const content = (await Filesystem.readText(full).catch(() => "")).trim() + + if (instance.project.vcs === "git") { + let diff = ( + await git(["-c", "core.fsmonitor=false", "diff", "--", file], { cwd: instance.directory }) + ).text() + if (!diff.trim()) { + diff = ( + await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { + cwd: instance.directory, + }) + ).text() + } + if (diff.trim()) { + const original = (await git(["show", `HEAD:${file}`], { cwd: instance.directory })).text() + const patch = structuredPatch(file, file, original, content, "old", "new", { + context: Infinity, + ignoreWhitespace: true, + }) + return { + type: "text", + content, + patch, + diff: formatPatch(patch), + } + } + } + + return { type: "text", content } + }) + }) + + const list = Effect.fn("File.list")(function* (dir?: string) { + return yield* Effect.promise(async () => { + const exclude = [".git", ".DS_Store"] + let ignored = (_: string) => false + if (instance.project.vcs === "git") { + const ig = ignore() + const gitignore = path.join(instance.project.worktree, ".gitignore") + if (await Filesystem.exists(gitignore)) { + ig.add(await Filesystem.readText(gitignore)) + } + const ignoreFile = path.join(instance.project.worktree, ".ignore") + if (await Filesystem.exists(ignoreFile)) { + ig.add(await Filesystem.readText(ignoreFile)) + } + ignored = ig.ignores.bind(ig) + } + + const resolved = dir ? path.join(instance.directory, dir) : instance.directory + if (!Instance.containsPath(resolved)) { + throw new Error("Access denied: path escapes project directory") + } + + const nodes: File.Node[] = [] + for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) { + if (exclude.includes(entry.name)) continue + const absolute = path.join(resolved, entry.name) + const file = path.relative(instance.directory, absolute) + const type = entry.isDirectory() ? "directory" : "file" + nodes.push({ + name: entry.name, + path: file, + absolute, + type, + ignored: ignored(type === "directory" ? file + "/" : file), + }) + } + + return nodes.sort((a, b) => { + if (a.type !== b.type) return a.type === "directory" ? -1 : 1 + return a.name.localeCompare(b.name) + }) + }) + }) + + const search = Effect.fn("File.search")(function* (input: { + query: string + limit?: number + dirs?: boolean + type?: "file" | "directory" + }) { + return yield* Effect.promise(async () => { + const query = input.query.trim() + const limit = input.limit ?? 100 + const kind = input.type ?? (input.dirs === false ? "file" : "all") + log.info("search", { query, kind }) + + const result = getFiles() + const preferHidden = query.startsWith(".") || query.includes("/.") + + if (!query) { + if (kind === "file") return result.files.slice(0, limit) + return sortHiddenLast(result.dirs.toSorted(), preferHidden).slice(0, limit) + } + + const items = + kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs] + + const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit + const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target) + const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted + + log.info("search", { query, kind, results: output.length }) + return output + }) + }) + + log.info("init") + return Service.of({ init, status, read, list, search }) + }), + ).pipe(Layer.fresh) +} diff --git a/packages/opencode/src/file/time-service.ts b/packages/opencode/src/file/time-service.ts new file mode 100644 index 000000000..a0fa8bfab --- /dev/null +++ b/packages/opencode/src/file/time-service.ts @@ -0,0 +1,93 @@ +import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect" +import { Flag } from "@/flag/flag" +import type { SessionID } from "@/session/schema" +import { Filesystem } from "../util/filesystem" +import { Log } from "../util/log" + +export namespace FileTime { + const log = Log.create({ service: "file.time" }) + + export type Stamp = { + readonly read: Date + readonly mtime: number | undefined + readonly ctime: number | undefined + readonly size: number | undefined + } + + const stamp = Effect.fnUntraced(function* (file: string) { + const stat = Filesystem.stat(file) + const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size + return { + read: yield* DateTime.nowAsDate, + mtime: stat?.mtime?.getTime(), + ctime: stat?.ctime?.getTime(), + size, + } + }) + + const session = (reads: Map>, sessionID: SessionID) => { + const value = reads.get(sessionID) + if (value) return value + + const next = new Map() + reads.set(sessionID, next) + return next + } + + export interface Interface { + readonly read: (sessionID: SessionID, file: string) => Effect.Effect + readonly get: (sessionID: SessionID, file: string) => Effect.Effect + readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect + readonly withLock: (filepath: string, fn: () => Promise) => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/FileTime") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK + const reads = new Map>() + const locks = new Map() + + const getLock = (filepath: string) => { + const lock = locks.get(filepath) + if (lock) return lock + + const next = Semaphore.makeUnsafe(1) + locks.set(filepath, next) + return next + } + + const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) { + log.info("read", { sessionID, file }) + session(reads, sessionID).set(file, yield* stamp(file)) + }) + + const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) { + return reads.get(sessionID)?.get(file)?.read + }) + + const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) { + if (disableCheck) return + + const time = reads.get(sessionID)?.get(filepath) + if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`) + + const next = yield* stamp(filepath) + const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size + if (!changed) return + + throw new Error( + `File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`, + ) + }) + + const withLock = Effect.fn("FileTime.withLock")(function* (filepath: string, fn: () => Promise) { + return yield* Effect.promise(fn).pipe(getLock(filepath).withPermits(1)) + }) + + return Service.of({ read, get, assert, withLock }) + }), + ).pipe(Layer.orDie, Layer.fresh) +} diff --git a/packages/opencode/src/file/time.ts b/packages/opencode/src/file/time.ts index 3d94bc122..b6d572fe8 100644 --- a/packages/opencode/src/file/time.ts +++ b/packages/opencode/src/file/time.ts @@ -1,110 +1,28 @@ -import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect" import { runPromiseInstance } from "@/effect/runtime" -import { Flag } from "@/flag/flag" import type { SessionID } from "@/session/schema" -import { Filesystem } from "../util/filesystem" -import { Log } from "../util/log" +import { FileTime as S } from "./time-service" export namespace FileTime { - const log = Log.create({ service: "file.time" }) + export type Stamp = S.Stamp - export type Stamp = { - readonly read: Date - readonly mtime: number | undefined - readonly ctime: number | undefined - readonly size: number | undefined - } + export type Interface = S.Interface - const stamp = Effect.fnUntraced(function* (file: string) { - const stat = Filesystem.stat(file) - const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size - return { - read: yield* DateTime.nowAsDate, - mtime: stat?.mtime?.getTime(), - ctime: stat?.ctime?.getTime(), - size, - } - }) - - const session = (reads: Map>, sessionID: SessionID) => { - const value = reads.get(sessionID) - if (value) return value - - const next = new Map() - reads.set(sessionID, next) - return next - } - - export interface Interface { - readonly read: (sessionID: SessionID, file: string) => Effect.Effect - readonly get: (sessionID: SessionID, file: string) => Effect.Effect - readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect - readonly withLock: (filepath: string, fn: () => Promise) => Effect.Effect - } - - export class Service extends ServiceMap.Service()("@opencode/FileTime") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK - const reads = new Map>() - const locks = new Map() - - const getLock = (filepath: string) => { - const lock = locks.get(filepath) - if (lock) return lock - - const next = Semaphore.makeUnsafe(1) - locks.set(filepath, next) - return next - } - - const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) { - log.info("read", { sessionID, file }) - session(reads, sessionID).set(file, yield* stamp(file)) - }) - - const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) { - return reads.get(sessionID)?.get(file)?.read - }) - - const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) { - if (disableCheck) return - - const time = reads.get(sessionID)?.get(filepath) - if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`) - - const next = yield* stamp(filepath) - const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size - if (!changed) return - - throw new Error( - `File ${filepath} has been modified since it was last read.\nLast modification: ${new Date(next.mtime ?? next.read.getTime()).toISOString()}\nLast read: ${time.read.toISOString()}\n\nPlease read the file again before modifying it.`, - ) - }) - - const withLock = Effect.fn("FileTime.withLock")(function* (filepath: string, fn: () => Promise) { - return yield* Effect.promise(fn).pipe(getLock(filepath).withPermits(1)) - }) - - return Service.of({ read, get, assert, withLock }) - }), - ) + export const Service = S.Service + export const layer = S.layer export function read(sessionID: SessionID, file: string) { - return runPromiseInstance(Service.use((s) => s.read(sessionID, file))) + return runPromiseInstance(S.Service.use((s) => s.read(sessionID, file))) } export function get(sessionID: SessionID, file: string) { - return runPromiseInstance(Service.use((s) => s.get(sessionID, file))) + return runPromiseInstance(S.Service.use((s) => s.get(sessionID, file))) } export async function assert(sessionID: SessionID, filepath: string) { - return runPromiseInstance(Service.use((s) => s.assert(sessionID, filepath))) + return runPromiseInstance(S.Service.use((s) => s.assert(sessionID, filepath))) } export async function withLock(filepath: string, fn: () => Promise): Promise { - return runPromiseInstance(Service.use((s) => s.withLock(filepath, fn))) + return runPromiseInstance(S.Service.use((s) => s.withLock(filepath, fn))) } } diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index 16ab3c6d3..7e5f5f7be 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -137,5 +137,5 @@ export namespace FileWatcher { return Effect.succeed(Service.of({})) }), ), - ) + ).pipe(Layer.orDie, Layer.fresh) } diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index 6da8caa08..e4381c69b 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -1,157 +1,16 @@ -import { Effect, Layer, ServiceMap } from "effect" import { runPromiseInstance } from "@/effect/runtime" -import { InstanceContext } from "@/effect/instance-context" -import path from "path" -import { mergeDeep } from "remeda" -import z from "zod" -import { Bus } from "../bus" -import { Config } from "../config/config" -import { File } from "../file" -import { Instance } from "../project/instance" -import { Process } from "../util/process" -import { Log } from "../util/log" -import * as Formatter from "./formatter" +import { Format as S } from "./service" export namespace Format { - const log = Log.create({ service: "format" }) + export const Status = S.Status + export type Status = S.Status - export const Status = z - .object({ - name: z.string(), - extensions: z.string().array(), - enabled: z.boolean(), - }) - .meta({ - ref: "FormatterStatus", - }) - export type Status = z.infer + export type Interface = S.Interface - export interface Interface { - readonly status: () => Effect.Effect - } - - export class Service extends ServiceMap.Service()("@opencode/Format") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const instance = yield* InstanceContext - - const enabled: Record = {} - const formatters: Record = {} - - const cfg = yield* Effect.promise(() => Config.get()) - - if (cfg.formatter !== false) { - for (const item of Object.values(Formatter)) { - formatters[item.name] = item - } - for (const [name, item] of Object.entries(cfg.formatter ?? {})) { - if (item.disabled) { - delete formatters[name] - continue - } - const info = mergeDeep(formatters[name] ?? {}, { - command: [], - extensions: [], - ...item, - }) - - if (info.command.length === 0) continue - - formatters[name] = { - ...info, - name, - enabled: async () => true, - } - } - } else { - log.info("all formatters are disabled") - } - - async function isEnabled(item: Formatter.Info) { - let status = enabled[item.name] - if (status === undefined) { - status = await item.enabled() - enabled[item.name] = status - } - return status - } - - async function getFormatter(ext: string) { - const result = [] - for (const item of Object.values(formatters)) { - log.info("checking", { name: item.name, ext }) - if (!item.extensions.includes(ext)) continue - if (!(await isEnabled(item))) continue - log.info("enabled", { name: item.name, ext }) - result.push(item) - } - return result - } - - yield* Effect.acquireRelease( - Effect.sync(() => - Bus.subscribe( - File.Event.Edited, - Instance.bind(async (payload) => { - const file = payload.properties.file - log.info("formatting", { file }) - const ext = path.extname(file) - - for (const item of await getFormatter(ext)) { - log.info("running", { command: item.command }) - try { - const proc = Process.spawn( - item.command.map((x) => x.replace("$FILE", file)), - { - cwd: instance.directory, - env: { ...process.env, ...item.environment }, - stdout: "ignore", - stderr: "ignore", - }, - ) - const exit = await proc.exited - if (exit !== 0) { - log.error("failed", { - command: item.command, - ...item.environment, - }) - } - } catch (error) { - log.error("failed to format file", { - error, - command: item.command, - ...item.environment, - file, - }) - } - } - }), - ), - ), - (unsubscribe) => Effect.sync(unsubscribe), - ) - log.info("init") - - const status = Effect.fn("Format.status")(function* () { - const result: Status[] = [] - for (const formatter of Object.values(formatters)) { - const isOn = yield* Effect.promise(() => isEnabled(formatter)) - result.push({ - name: formatter.name, - extensions: formatter.extensions, - enabled: isOn, - }) - } - return result - }) - - return Service.of({ status }) - }), - ) + export const Service = S.Service + export const layer = S.layer export async function status() { - return runPromiseInstance(Service.use((s) => s.status())) + return runPromiseInstance(S.Service.use((s) => s.status())) } } diff --git a/packages/opencode/src/format/service.ts b/packages/opencode/src/format/service.ts new file mode 100644 index 000000000..64fff7949 --- /dev/null +++ b/packages/opencode/src/format/service.ts @@ -0,0 +1,152 @@ +import { Effect, Layer, ServiceMap } from "effect" +import { InstanceContext } from "@/effect/instance-context" +import path from "path" +import { mergeDeep } from "remeda" +import z from "zod" +import { Bus } from "../bus" +import { Config } from "../config/config" +import { File } from "../file/service" +import { Instance } from "../project/instance" +import { Process } from "../util/process" +import { Log } from "../util/log" +import * as Formatter from "./formatter" + +export namespace Format { + const log = Log.create({ service: "format" }) + + export const Status = z + .object({ + name: z.string(), + extensions: z.string().array(), + enabled: z.boolean(), + }) + .meta({ + ref: "FormatterStatus", + }) + export type Status = z.infer + + export interface Interface { + readonly status: () => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/Format") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const instance = yield* InstanceContext + + const enabled: Record = {} + const formatters: Record = {} + + const cfg = yield* Effect.promise(() => Config.get()) + + if (cfg.formatter !== false) { + for (const item of Object.values(Formatter)) { + formatters[item.name] = item + } + for (const [name, item] of Object.entries(cfg.formatter ?? {})) { + if (item.disabled) { + delete formatters[name] + continue + } + const info = mergeDeep(formatters[name] ?? {}, { + command: [], + extensions: [], + ...item, + }) + + if (info.command.length === 0) continue + + formatters[name] = { + ...info, + name, + enabled: async () => true, + } + } + } else { + log.info("all formatters are disabled") + } + + async function isEnabled(item: Formatter.Info) { + let status = enabled[item.name] + if (status === undefined) { + status = await item.enabled() + enabled[item.name] = status + } + return status + } + + async function getFormatter(ext: string) { + const result = [] + for (const item of Object.values(formatters)) { + log.info("checking", { name: item.name, ext }) + if (!item.extensions.includes(ext)) continue + if (!(await isEnabled(item))) continue + log.info("enabled", { name: item.name, ext }) + result.push(item) + } + return result + } + + yield* Effect.acquireRelease( + Effect.sync(() => + Bus.subscribe( + File.Event.Edited, + Instance.bind(async (payload) => { + const file = payload.properties.file + log.info("formatting", { file }) + const ext = path.extname(file) + + for (const item of await getFormatter(ext)) { + log.info("running", { command: item.command }) + try { + const proc = Process.spawn( + item.command.map((x) => x.replace("$FILE", file)), + { + cwd: instance.directory, + env: { ...process.env, ...item.environment }, + stdout: "ignore", + stderr: "ignore", + }, + ) + const exit = await proc.exited + if (exit !== 0) { + log.error("failed", { + command: item.command, + ...item.environment, + }) + } + } catch (error) { + log.error("failed to format file", { + error, + command: item.command, + ...item.environment, + file, + }) + } + } + }), + ), + ), + (unsubscribe) => Effect.sync(unsubscribe), + ) + log.info("init") + + const status = Effect.fn("Format.status")(function* () { + const result: Status[] = [] + for (const formatter of Object.values(formatters)) { + const isOn = yield* Effect.promise(() => isEnabled(formatter)) + result.push({ + name: formatter.name, + extensions: formatter.extensions, + enabled: isOn, + }) + } + return result + }) + + return Service.of({ status }) + }), + ).pipe(Layer.fresh) +} diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 321c5c374..01ac76897 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -1,292 +1,52 @@ import { runPromiseInstance } from "@/effect/runtime" -import { Bus } from "@/bus" -import { BusEvent } from "@/bus/bus-event" -import { Config } from "@/config/config" -import { InstanceContext } from "@/effect/instance-context" -import { ProjectID } from "@/project/schema" -import { MessageID, SessionID } from "@/session/schema" -import { PermissionTable } from "@/session/session.sql" -import { Database, eq } from "@/storage/db" import { fn } from "@/util/fn" -import { Log } from "@/util/log" -import { Wildcard } from "@/util/wildcard" -import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect" -import os from "os" import z from "zod" -import { evaluate as evalRule } from "./evaluate" -import { PermissionID } from "./schema" +import { Permission as S } from "./service" export namespace PermissionNext { - const log = Log.create({ service: "permission" }) + export const Action = S.Action + export type Action = S.Action - export const Action = z.enum(["allow", "deny", "ask"]).meta({ - ref: "PermissionAction", - }) - export type Action = z.infer + export const Rule = S.Rule + export type Rule = S.Rule - export const Rule = z - .object({ - permission: z.string(), - pattern: z.string(), - action: Action, - }) - .meta({ - ref: "PermissionRule", - }) - export type Rule = z.infer + export const Ruleset = S.Ruleset + export type Ruleset = S.Ruleset - export const Ruleset = Rule.array().meta({ - ref: "PermissionRuleset", - }) - export type Ruleset = z.infer + export const Request = S.Request + export type Request = S.Request - export const Request = z - .object({ - id: PermissionID.zod, - sessionID: SessionID.zod, - permission: z.string(), - patterns: z.string().array(), - metadata: z.record(z.string(), z.any()), - always: z.string().array(), - tool: z - .object({ - messageID: MessageID.zod, - callID: z.string(), - }) - .optional(), - }) - .meta({ - ref: "PermissionRequest", - }) - export type Request = z.infer + export const Reply = S.Reply + export type Reply = S.Reply - export const Reply = z.enum(["once", "always", "reject"]) - export type Reply = z.infer + export const Approval = S.Approval + export type Approval = z.infer - export const Approval = z.object({ - projectID: ProjectID.zod, - patterns: z.string().array(), - }) + export const Event = S.Event - export const Event = { - Asked: BusEvent.define("permission.asked", Request), - Replied: BusEvent.define( - "permission.replied", - z.object({ - sessionID: SessionID.zod, - requestID: PermissionID.zod, - reply: Reply, - }), - ), - } + export const RejectedError = S.RejectedError + export const CorrectedError = S.CorrectedError + export const DeniedError = S.DeniedError + export type Error = S.Error - export class RejectedError extends Schema.TaggedErrorClass()("PermissionRejectedError", {}) { - override get message() { - return "The user rejected permission to use this specific tool call." - } - } + export const AskInput = S.AskInput + export const ReplyInput = S.ReplyInput - export class CorrectedError extends Schema.TaggedErrorClass()("PermissionCorrectedError", { - feedback: Schema.String, - }) { - override get message() { - return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}` - } - } + export type Interface = S.Interface - export class DeniedError extends Schema.TaggedErrorClass()("PermissionDeniedError", { - ruleset: Schema.Any, - }) { - override get message() { - return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}` - } - } + export const Service = S.Service + export const layer = S.layer - export type Error = DeniedError | RejectedError | CorrectedError + export const evaluate = S.evaluate + export const fromConfig = S.fromConfig + export const merge = S.merge + export const disabled = S.disabled - export const AskInput = Request.partial({ id: true }).extend({ - ruleset: Ruleset, - }) + export const ask = fn(S.AskInput, async (input) => runPromiseInstance(S.Service.use((s) => s.ask(input)))) - export const ReplyInput = z.object({ - requestID: PermissionID.zod, - reply: Reply, - message: z.string().optional(), - }) - - export interface Interface { - readonly ask: (input: z.infer) => Effect.Effect - readonly reply: (input: z.infer) => Effect.Effect - readonly list: () => Effect.Effect - } - - interface PendingEntry { - info: Request - deferred: Deferred.Deferred - } - - export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { - log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() }) - return evalRule(permission, pattern, ...rulesets) - } - - export class Service extends ServiceMap.Service()("@opencode/PermissionNext") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const { project } = yield* InstanceContext - const row = Database.use((db) => - db.select().from(PermissionTable).where(eq(PermissionTable.project_id, project.id)).get(), - ) - const pending = new Map() - const approved: Ruleset = row?.data ?? [] - - const ask = Effect.fn("Permission.ask")(function* (input: z.infer) { - const { ruleset, ...request } = input - let needsAsk = false - - for (const pattern of request.patterns) { - const rule = evaluate(request.permission, pattern, ruleset, approved) - log.info("evaluated", { permission: request.permission, pattern, action: rule }) - if (rule.action === "deny") { - return yield* new DeniedError({ - ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)), - }) - } - if (rule.action === "allow") continue - needsAsk = true - } - - if (!needsAsk) return - - const id = request.id ?? PermissionID.ascending() - const info: Request = { - id, - ...request, - } - log.info("asking", { id, permission: info.permission, patterns: info.patterns }) - - const deferred = yield* Deferred.make() - pending.set(id, { info, deferred }) - void Bus.publish(Event.Asked, info) - return yield* Effect.ensuring( - Deferred.await(deferred), - Effect.sync(() => { - pending.delete(id) - }), - ) - }) - - const reply = Effect.fn("Permission.reply")(function* (input: z.infer) { - const existing = pending.get(input.requestID) - if (!existing) return - - pending.delete(input.requestID) - void Bus.publish(Event.Replied, { - sessionID: existing.info.sessionID, - requestID: existing.info.id, - reply: input.reply, - }) - - if (input.reply === "reject") { - yield* Deferred.fail( - existing.deferred, - input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(), - ) - - for (const [id, item] of pending.entries()) { - if (item.info.sessionID !== existing.info.sessionID) continue - pending.delete(id) - void Bus.publish(Event.Replied, { - sessionID: item.info.sessionID, - requestID: item.info.id, - reply: "reject", - }) - yield* Deferred.fail(item.deferred, new RejectedError()) - } - return - } - - yield* Deferred.succeed(existing.deferred, undefined) - if (input.reply === "once") return - - for (const pattern of existing.info.always) { - approved.push({ - permission: existing.info.permission, - pattern, - action: "allow", - }) - } - - for (const [id, item] of pending.entries()) { - if (item.info.sessionID !== existing.info.sessionID) continue - const ok = item.info.patterns.every( - (pattern) => evaluate(item.info.permission, pattern, approved).action === "allow", - ) - if (!ok) continue - pending.delete(id) - void Bus.publish(Event.Replied, { - sessionID: item.info.sessionID, - requestID: item.info.id, - reply: "always", - }) - yield* Deferred.succeed(item.deferred, undefined) - } - }) - - const list = Effect.fn("Permission.list")(function* () { - return Array.from(pending.values(), (item) => item.info) - }) - - return Service.of({ ask, reply, list }) - }), - ) - - function expand(pattern: string): string { - if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1) - if (pattern === "~") return os.homedir() - if (pattern.startsWith("$HOME/")) return os.homedir() + pattern.slice(5) - if (pattern.startsWith("$HOME")) return os.homedir() + pattern.slice(5) - return pattern - } - - export function fromConfig(permission: Config.Permission) { - const ruleset: Ruleset = [] - for (const [key, value] of Object.entries(permission)) { - if (typeof value === "string") { - ruleset.push({ permission: key, action: value, pattern: "*" }) - continue - } - ruleset.push( - ...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })), - ) - } - return ruleset - } - - export function merge(...rulesets: Ruleset[]): Ruleset { - return rulesets.flat() - } - - export const ask = fn(AskInput, async (input) => runPromiseInstance(Service.use((svc) => svc.ask(input)))) - - export const reply = fn(ReplyInput, async (input) => runPromiseInstance(Service.use((svc) => svc.reply(input)))) + export const reply = fn(S.ReplyInput, async (input) => runPromiseInstance(S.Service.use((s) => s.reply(input)))) export async function list() { - return runPromiseInstance(Service.use((svc) => svc.list())) - } - - const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"] - - export function disabled(tools: string[], ruleset: Ruleset): Set { - const result = new Set() - for (const tool of tools) { - const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool - const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission)) - if (!rule) continue - if (rule.pattern === "*" && rule.action === "deny") result.add(tool) - } - return result + return runPromiseInstance(S.Service.use((s) => s.list())) } } diff --git a/packages/opencode/src/permission/service.ts b/packages/opencode/src/permission/service.ts new file mode 100644 index 000000000..08475520b --- /dev/null +++ b/packages/opencode/src/permission/service.ts @@ -0,0 +1,282 @@ +import { Bus } from "@/bus" +import { BusEvent } from "@/bus/bus-event" +import { Config } from "@/config/config" +import { InstanceContext } from "@/effect/instance-context" +import { ProjectID } from "@/project/schema" +import { MessageID, SessionID } from "@/session/schema" +import { PermissionTable } from "@/session/session.sql" +import { Database, eq } from "@/storage/db" +import { Log } from "@/util/log" +import { Wildcard } from "@/util/wildcard" +import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect" +import os from "os" +import z from "zod" +import { evaluate as evalRule } from "./evaluate" +import { PermissionID } from "./schema" + +export namespace Permission { + const log = Log.create({ service: "permission" }) + + export const Action = z.enum(["allow", "deny", "ask"]).meta({ + ref: "PermissionAction", + }) + export type Action = z.infer + + export const Rule = z + .object({ + permission: z.string(), + pattern: z.string(), + action: Action, + }) + .meta({ + ref: "PermissionRule", + }) + export type Rule = z.infer + + export const Ruleset = Rule.array().meta({ + ref: "PermissionRuleset", + }) + export type Ruleset = z.infer + + export const Request = z + .object({ + id: PermissionID.zod, + sessionID: SessionID.zod, + permission: z.string(), + patterns: z.string().array(), + metadata: z.record(z.string(), z.any()), + always: z.string().array(), + tool: z + .object({ + messageID: MessageID.zod, + callID: z.string(), + }) + .optional(), + }) + .meta({ + ref: "PermissionRequest", + }) + export type Request = z.infer + + export const Reply = z.enum(["once", "always", "reject"]) + export type Reply = z.infer + + export const Approval = z.object({ + projectID: ProjectID.zod, + patterns: z.string().array(), + }) + + export const Event = { + Asked: BusEvent.define("permission.asked", Request), + Replied: BusEvent.define( + "permission.replied", + z.object({ + sessionID: SessionID.zod, + requestID: PermissionID.zod, + reply: Reply, + }), + ), + } + + export class RejectedError extends Schema.TaggedErrorClass()("PermissionRejectedError", {}) { + override get message() { + return "The user rejected permission to use this specific tool call." + } + } + + export class CorrectedError extends Schema.TaggedErrorClass()("PermissionCorrectedError", { + feedback: Schema.String, + }) { + override get message() { + return `The user rejected permission to use this specific tool call with the following feedback: ${this.feedback}` + } + } + + export class DeniedError extends Schema.TaggedErrorClass()("PermissionDeniedError", { + ruleset: Schema.Any, + }) { + override get message() { + return `The user has specified a rule which prevents you from using this specific tool call. Here are some of the relevant rules ${JSON.stringify(this.ruleset)}` + } + } + + export type Error = DeniedError | RejectedError | CorrectedError + + export const AskInput = Request.partial({ id: true }).extend({ + ruleset: Ruleset, + }) + + export const ReplyInput = z.object({ + requestID: PermissionID.zod, + reply: Reply, + message: z.string().optional(), + }) + + export interface Interface { + readonly ask: (input: z.infer) => Effect.Effect + readonly reply: (input: z.infer) => Effect.Effect + readonly list: () => Effect.Effect + } + + interface PendingEntry { + info: Request + deferred: Deferred.Deferred + } + + export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule { + log.info("evaluate", { permission, pattern, ruleset: rulesets.flat() }) + return evalRule(permission, pattern, ...rulesets) + } + + export class Service extends ServiceMap.Service()("@opencode/PermissionNext") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const { project } = yield* InstanceContext + const row = Database.use((db) => + db.select().from(PermissionTable).where(eq(PermissionTable.project_id, project.id)).get(), + ) + const pending = new Map() + const approved: Ruleset = row?.data ?? [] + + const ask = Effect.fn("Permission.ask")(function* (input: z.infer) { + const { ruleset, ...request } = input + let needsAsk = false + + for (const pattern of request.patterns) { + const rule = evaluate(request.permission, pattern, ruleset, approved) + log.info("evaluated", { permission: request.permission, pattern, action: rule }) + if (rule.action === "deny") { + return yield* new DeniedError({ + ruleset: ruleset.filter((rule) => Wildcard.match(request.permission, rule.permission)), + }) + } + if (rule.action === "allow") continue + needsAsk = true + } + + if (!needsAsk) return + + const id = request.id ?? PermissionID.ascending() + const info: Request = { + id, + ...request, + } + log.info("asking", { id, permission: info.permission, patterns: info.patterns }) + + const deferred = yield* Deferred.make() + pending.set(id, { info, deferred }) + void Bus.publish(Event.Asked, info) + return yield* Effect.ensuring( + Deferred.await(deferred), + Effect.sync(() => { + pending.delete(id) + }), + ) + }) + + const reply = Effect.fn("Permission.reply")(function* (input: z.infer) { + const existing = pending.get(input.requestID) + if (!existing) return + + pending.delete(input.requestID) + void Bus.publish(Event.Replied, { + sessionID: existing.info.sessionID, + requestID: existing.info.id, + reply: input.reply, + }) + + if (input.reply === "reject") { + yield* Deferred.fail( + existing.deferred, + input.message ? new CorrectedError({ feedback: input.message }) : new RejectedError(), + ) + + for (const [id, item] of pending.entries()) { + if (item.info.sessionID !== existing.info.sessionID) continue + pending.delete(id) + void Bus.publish(Event.Replied, { + sessionID: item.info.sessionID, + requestID: item.info.id, + reply: "reject", + }) + yield* Deferred.fail(item.deferred, new RejectedError()) + } + return + } + + yield* Deferred.succeed(existing.deferred, undefined) + if (input.reply === "once") return + + for (const pattern of existing.info.always) { + approved.push({ + permission: existing.info.permission, + pattern, + action: "allow", + }) + } + + for (const [id, item] of pending.entries()) { + if (item.info.sessionID !== existing.info.sessionID) continue + const ok = item.info.patterns.every( + (pattern) => evaluate(item.info.permission, pattern, approved).action === "allow", + ) + if (!ok) continue + pending.delete(id) + void Bus.publish(Event.Replied, { + sessionID: item.info.sessionID, + requestID: item.info.id, + reply: "always", + }) + yield* Deferred.succeed(item.deferred, undefined) + } + }) + + const list = Effect.fn("Permission.list")(function* () { + return Array.from(pending.values(), (item) => item.info) + }) + + return Service.of({ ask, reply, list }) + }), + ).pipe(Layer.fresh) + + function expand(pattern: string): string { + if (pattern.startsWith("~/")) return os.homedir() + pattern.slice(1) + if (pattern === "~") return os.homedir() + if (pattern.startsWith("$HOME/")) return os.homedir() + pattern.slice(5) + if (pattern.startsWith("$HOME")) return os.homedir() + pattern.slice(5) + return pattern + } + + export function fromConfig(permission: Config.Permission) { + const ruleset: Ruleset = [] + for (const [key, value] of Object.entries(permission)) { + if (typeof value === "string") { + ruleset.push({ permission: key, action: value, pattern: "*" }) + continue + } + ruleset.push( + ...Object.entries(value).map(([pattern, action]) => ({ permission: key, pattern: expand(pattern), action })), + ) + } + return ruleset + } + + export function merge(...rulesets: Ruleset[]): Ruleset { + return rulesets.flat() + } + + const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"] + + export function disabled(tools: string[], ruleset: Ruleset): Set { + const result = new Set() + for (const tool of tools) { + const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool + const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission)) + if (!rule) continue + if (rule.pattern === "*" && rule.action === "deny") result.add(tool) + } + return result + } +} diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index 9e85571c4..9a9e42ecf 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -79,5 +79,5 @@ export namespace Vcs { }), }) }), - ) + ).pipe(Layer.fresh) } diff --git a/packages/opencode/src/provider/auth-service.ts b/packages/opencode/src/provider/auth-service.ts new file mode 100644 index 000000000..5045e1edd --- /dev/null +++ b/packages/opencode/src/provider/auth-service.ts @@ -0,0 +1,215 @@ +import type { AuthOuathResult } from "@opencode-ai/plugin" +import { NamedError } from "@opencode-ai/util/error" +import * as Auth from "@/auth/effect" +import { ProviderID } from "./schema" +import { Array as Arr, Effect, Layer, Record, Result, ServiceMap, Struct } from "effect" +import z from "zod" + +export namespace ProviderAuth { + export const Method = z + .object({ + type: z.union([z.literal("oauth"), z.literal("api")]), + label: z.string(), + prompts: z + .array( + z.union([ + z.object({ + type: z.literal("text"), + key: z.string(), + message: z.string(), + placeholder: z.string().optional(), + when: z + .object({ + key: z.string(), + op: z.union([z.literal("eq"), z.literal("neq")]), + value: z.string(), + }) + .optional(), + }), + z.object({ + type: z.literal("select"), + key: z.string(), + message: z.string(), + options: z.array( + z.object({ + label: z.string(), + value: z.string(), + hint: z.string().optional(), + }), + ), + when: z + .object({ + key: z.string(), + op: z.union([z.literal("eq"), z.literal("neq")]), + value: z.string(), + }) + .optional(), + }), + ]), + ) + .optional(), + }) + .meta({ + ref: "ProviderAuthMethod", + }) + export type Method = z.infer + + export const Authorization = z + .object({ + url: z.string(), + method: z.union([z.literal("auto"), z.literal("code")]), + instructions: z.string(), + }) + .meta({ + ref: "ProviderAuthAuthorization", + }) + export type Authorization = z.infer + + export const OauthMissing = NamedError.create("ProviderAuthOauthMissing", z.object({ providerID: ProviderID.zod })) + + export const OauthCodeMissing = NamedError.create( + "ProviderAuthOauthCodeMissing", + z.object({ providerID: ProviderID.zod }), + ) + + export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({})) + + export const ValidationFailed = NamedError.create( + "ProviderAuthValidationFailed", + z.object({ + field: z.string(), + message: z.string(), + }), + ) + + export type Error = + | Auth.AuthError + | InstanceType + | InstanceType + | InstanceType + | InstanceType + + export interface Interface { + readonly methods: () => Effect.Effect> + readonly authorize: (input: { + providerID: ProviderID + method: number + inputs?: Record + }) => Effect.Effect + readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/ProviderAuth") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const auth = yield* Auth.Auth.Service + const hooks = yield* Effect.promise(async () => { + const mod = await import("../plugin") + const plugins = await mod.Plugin.list() + return Record.fromEntries( + Arr.filterMap(plugins, (x) => + x.auth?.provider !== undefined + ? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const) + : Result.failVoid, + ), + ) + }) + const pending = new Map() + + const methods = Effect.fn("ProviderAuth.methods")(function* () { + return Record.map(hooks, (item) => + item.methods.map( + (method): Method => ({ + type: method.type, + label: method.label, + prompts: method.prompts?.map((prompt) => { + if (prompt.type === "select") { + return { + type: "select" as const, + key: prompt.key, + message: prompt.message, + options: prompt.options, + when: prompt.when, + } + } + return { + type: "text" as const, + key: prompt.key, + message: prompt.message, + placeholder: prompt.placeholder, + when: prompt.when, + } + }), + }), + ), + ) + }) + + const authorize = Effect.fn("ProviderAuth.authorize")(function* (input: { + providerID: ProviderID + method: number + inputs?: Record + }) { + const method = hooks[input.providerID].methods[input.method] + if (method.type !== "oauth") return + + if (method.prompts && input.inputs) { + for (const prompt of method.prompts) { + if (prompt.type === "text" && prompt.validate && input.inputs[prompt.key] !== undefined) { + const error = prompt.validate(input.inputs[prompt.key]) + if (error) return yield* Effect.fail(new ValidationFailed({ field: prompt.key, message: error })) + } + } + } + + const result = yield* Effect.promise(() => method.authorize(input.inputs)) + pending.set(input.providerID, result) + return { + url: result.url, + method: result.method, + instructions: result.instructions, + } + }) + + const callback = Effect.fn("ProviderAuth.callback")(function* (input: { + providerID: ProviderID + method: number + code?: string + }) { + const match = pending.get(input.providerID) + if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID })) + if (match.method === "code" && !input.code) { + return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID })) + } + + const result = yield* Effect.promise(() => + match.method === "code" ? match.callback(input.code!) : match.callback(), + ) + if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({})) + + if ("key" in result) { + yield* auth.set(input.providerID, { + type: "api", + key: result.key, + }) + } + + if ("refresh" in result) { + yield* auth.set(input.providerID, { + type: "oauth", + access: result.access, + refresh: result.refresh, + expires: result.expires, + ...(result.accountId ? { accountId: result.accountId } : {}), + }) + } + }) + + return Service.of({ methods, authorize, callback }) + }), + ).pipe(Layer.fresh) + + export const defaultLayer = layer.pipe(Layer.provide(Auth.Auth.layer)) +} diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index fe6409776..ff3433797 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -1,222 +1,30 @@ -import type { AuthOuathResult } from "@opencode-ai/plugin" -import { NamedError } from "@opencode-ai/util/error" -import * as Auth from "@/auth/effect" import { runPromiseInstance } from "@/effect/runtime" import { fn } from "@/util/fn" import { ProviderID } from "./schema" -import { Array as Arr, Effect, Layer, Record, Result, ServiceMap, Struct } from "effect" import z from "zod" +import { ProviderAuth as S } from "./auth-service" export namespace ProviderAuth { - export const Method = z - .object({ - type: z.union([z.literal("oauth"), z.literal("api")]), - label: z.string(), - prompts: z - .array( - z.union([ - z.object({ - type: z.literal("text"), - key: z.string(), - message: z.string(), - placeholder: z.string().optional(), - when: z - .object({ - key: z.string(), - op: z.union([z.literal("eq"), z.literal("neq")]), - value: z.string(), - }) - .optional(), - }), - z.object({ - type: z.literal("select"), - key: z.string(), - message: z.string(), - options: z.array( - z.object({ - label: z.string(), - value: z.string(), - hint: z.string().optional(), - }), - ), - when: z - .object({ - key: z.string(), - op: z.union([z.literal("eq"), z.literal("neq")]), - value: z.string(), - }) - .optional(), - }), - ]), - ) - .optional(), - }) - .meta({ - ref: "ProviderAuthMethod", - }) - export type Method = z.infer + export const Method = S.Method + export type Method = S.Method - export const Authorization = z - .object({ - url: z.string(), - method: z.union([z.literal("auto"), z.literal("code")]), - instructions: z.string(), - }) - .meta({ - ref: "ProviderAuthAuthorization", - }) - export type Authorization = z.infer + export const Authorization = S.Authorization + export type Authorization = S.Authorization - export const OauthMissing = NamedError.create("ProviderAuthOauthMissing", z.object({ providerID: ProviderID.zod })) + export const OauthMissing = S.OauthMissing + export const OauthCodeMissing = S.OauthCodeMissing + export const OauthCallbackFailed = S.OauthCallbackFailed + export const ValidationFailed = S.ValidationFailed + export type Error = S.Error - export const OauthCodeMissing = NamedError.create( - "ProviderAuthOauthCodeMissing", - z.object({ providerID: ProviderID.zod }), - ) + export type Interface = S.Interface - export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({})) - - export const ValidationFailed = NamedError.create( - "ProviderAuthValidationFailed", - z.object({ - field: z.string(), - message: z.string(), - }), - ) - - export type Error = - | Auth.AuthError - | InstanceType - | InstanceType - | InstanceType - | InstanceType - - export interface Interface { - readonly methods: () => Effect.Effect> - readonly authorize: (input: { - providerID: ProviderID - method: number - inputs?: Record - }) => Effect.Effect - readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect - } - - export class Service extends ServiceMap.Service()("@opencode/ProviderAuth") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const auth = yield* Auth.Auth.Service - const hooks = yield* Effect.promise(async () => { - const mod = await import("../plugin") - const plugins = await mod.Plugin.list() - return Record.fromEntries( - Arr.filterMap(plugins, (x) => - x.auth?.provider !== undefined - ? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const) - : Result.failVoid, - ), - ) - }) - const pending = new Map() - - const methods = Effect.fn("ProviderAuth.methods")(function* () { - return Record.map(hooks, (item) => - item.methods.map( - (method): Method => ({ - type: method.type, - label: method.label, - prompts: method.prompts?.map((prompt) => { - if (prompt.type === "select") { - return { - type: "select" as const, - key: prompt.key, - message: prompt.message, - options: prompt.options, - when: prompt.when, - } - } - return { - type: "text" as const, - key: prompt.key, - message: prompt.message, - placeholder: prompt.placeholder, - when: prompt.when, - } - }), - }), - ), - ) - }) - - const authorize = Effect.fn("ProviderAuth.authorize")(function* (input: { - providerID: ProviderID - method: number - inputs?: Record - }) { - const method = hooks[input.providerID].methods[input.method] - if (method.type !== "oauth") return - - if (method.prompts && input.inputs) { - for (const prompt of method.prompts) { - if (prompt.type === "text" && prompt.validate && input.inputs[prompt.key] !== undefined) { - const error = prompt.validate(input.inputs[prompt.key]) - if (error) return yield* Effect.fail(new ValidationFailed({ field: prompt.key, message: error })) - } - } - } - - const result = yield* Effect.promise(() => method.authorize(input.inputs)) - pending.set(input.providerID, result) - return { - url: result.url, - method: result.method, - instructions: result.instructions, - } - }) - - const callback = Effect.fn("ProviderAuth.callback")(function* (input: { - providerID: ProviderID - method: number - code?: string - }) { - const match = pending.get(input.providerID) - if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID })) - if (match.method === "code" && !input.code) { - return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID })) - } - - const result = yield* Effect.promise(() => - match.method === "code" ? match.callback(input.code!) : match.callback(), - ) - if (!result || result.type !== "success") return yield* Effect.fail(new OauthCallbackFailed({})) - - if ("key" in result) { - yield* auth.set(input.providerID, { - type: "api", - key: result.key, - }) - } - - if ("refresh" in result) { - yield* auth.set(input.providerID, { - type: "oauth", - access: result.access, - refresh: result.refresh, - expires: result.expires, - ...(result.accountId ? { accountId: result.accountId } : {}), - }) - } - }) - - return Service.of({ methods, authorize, callback }) - }), - ) - - export const defaultLayer = layer.pipe(Layer.provide(Auth.Auth.layer)) + export const Service = S.Service + export const layer = S.layer + export const defaultLayer = S.defaultLayer export async function methods() { - return runPromiseInstance(Service.use((svc) => svc.methods())) + return runPromiseInstance(S.Service.use((svc) => svc.methods())) } export const authorize = fn( @@ -225,7 +33,7 @@ export namespace ProviderAuth { method: z.number(), inputs: z.record(z.string(), z.string()).optional(), }), - async (input): Promise => runPromiseInstance(Service.use((svc) => svc.authorize(input))), + async (input): Promise => runPromiseInstance(S.Service.use((svc) => svc.authorize(input))), ) export const callback = fn( @@ -234,6 +42,6 @@ export namespace ProviderAuth { method: z.number(), code: z.string().optional(), }), - async (input) => runPromiseInstance(Service.use((svc) => svc.callback(input))), + async (input) => runPromiseInstance(S.Service.use((svc) => svc.callback(input))), ) } diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index 551c51399..de0095190 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -1,193 +1,49 @@ -import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect" import { runPromiseInstance } from "@/effect/runtime" -import { Bus } from "@/bus" -import { BusEvent } from "@/bus/bus-event" -import { SessionID, MessageID } from "@/session/schema" -import { Log } from "@/util/log" -import z from "zod" -import { QuestionID } from "./schema" - -const log = Log.create({ service: "question" }) +import type { MessageID, SessionID } from "@/session/schema" +import type { QuestionID } from "./schema" +import { Question as S } from "./service" export namespace Question { - // Schemas + export const Option = S.Option + export type Option = S.Option - export const Option = z - .object({ - label: z.string().describe("Display text (1-5 words, concise)"), - description: z.string().describe("Explanation of choice"), - }) - .meta({ ref: "QuestionOption" }) - export type Option = z.infer + export const Info = S.Info + export type Info = S.Info - export const Info = z - .object({ - question: z.string().describe("Complete question"), - header: z.string().describe("Very short label (max 30 chars)"), - options: z.array(Option).describe("Available choices"), - multiple: z.boolean().optional().describe("Allow selecting multiple choices"), - custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"), - }) - .meta({ ref: "QuestionInfo" }) - export type Info = z.infer + export const Request = S.Request + export type Request = S.Request - export const Request = z - .object({ - id: QuestionID.zod, - sessionID: SessionID.zod, - questions: z.array(Info).describe("Questions to ask"), - tool: z - .object({ - messageID: MessageID.zod, - callID: z.string(), - }) - .optional(), - }) - .meta({ ref: "QuestionRequest" }) - export type Request = z.infer + export const Answer = S.Answer + export type Answer = S.Answer - export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" }) - export type Answer = z.infer + export const Reply = S.Reply + export type Reply = S.Reply - export const Reply = z.object({ - answers: z - .array(Answer) - .describe("User answers in order of questions (each answer is an array of selected labels)"), - }) - export type Reply = z.infer + export const Event = S.Event + export const RejectedError = S.RejectedError - export const Event = { - Asked: BusEvent.define("question.asked", Request), - Replied: BusEvent.define( - "question.replied", - z.object({ - sessionID: SessionID.zod, - requestID: QuestionID.zod, - answers: z.array(Answer), - }), - ), - Rejected: BusEvent.define( - "question.rejected", - z.object({ - sessionID: SessionID.zod, - requestID: QuestionID.zod, - }), - ), - } + export type Interface = S.Interface - export class RejectedError extends Schema.TaggedErrorClass()("QuestionRejectedError", {}) { - override get message() { - return "The user dismissed this question" - } - } - - interface PendingEntry { - info: Request - deferred: Deferred.Deferred - } - - // Service - - export interface Interface { - readonly ask: (input: { - sessionID: SessionID - questions: Info[] - tool?: { messageID: MessageID; callID: string } - }) => Effect.Effect - readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect - readonly reject: (requestID: QuestionID) => Effect.Effect - readonly list: () => Effect.Effect - } - - export class Service extends ServiceMap.Service()("@opencode/Question") {} - - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const pending = new Map() - - const ask = Effect.fn("Question.ask")(function* (input: { - sessionID: SessionID - questions: Info[] - tool?: { messageID: MessageID; callID: string } - }) { - const id = QuestionID.ascending() - log.info("asking", { id, questions: input.questions.length }) - - const deferred = yield* Deferred.make() - const info: Request = { - id, - sessionID: input.sessionID, - questions: input.questions, - tool: input.tool, - } - pending.set(id, { info, deferred }) - Bus.publish(Event.Asked, info) - - return yield* Effect.ensuring( - Deferred.await(deferred), - Effect.sync(() => { - pending.delete(id) - }), - ) - }) - - const reply = Effect.fn("Question.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) { - const existing = pending.get(input.requestID) - if (!existing) { - log.warn("reply for unknown request", { requestID: input.requestID }) - return - } - pending.delete(input.requestID) - log.info("replied", { requestID: input.requestID, answers: input.answers }) - Bus.publish(Event.Replied, { - sessionID: existing.info.sessionID, - requestID: existing.info.id, - answers: input.answers, - }) - yield* Deferred.succeed(existing.deferred, input.answers) - }) - - const reject = Effect.fn("Question.reject")(function* (requestID: QuestionID) { - const existing = pending.get(requestID) - if (!existing) { - log.warn("reject for unknown request", { requestID }) - return - } - pending.delete(requestID) - log.info("rejected", { requestID }) - Bus.publish(Event.Rejected, { - sessionID: existing.info.sessionID, - requestID: existing.info.id, - }) - yield* Deferred.fail(existing.deferred, new RejectedError()) - }) - - const list = Effect.fn("Question.list")(function* () { - return Array.from(pending.values(), (x) => x.info) - }) - - return Service.of({ ask, reply, reject, list }) - }), - ) + export const Service = S.Service + export const layer = S.layer export async function ask(input: { sessionID: SessionID questions: Info[] tool?: { messageID: MessageID; callID: string } }): Promise { - return runPromiseInstance(Service.use((svc) => svc.ask(input))) + return runPromiseInstance(S.Service.use((s) => s.ask(input))) } - export async function reply(input: { requestID: QuestionID; answers: Answer[] }): Promise { - return runPromiseInstance(Service.use((svc) => svc.reply(input))) + export async function reply(input: { requestID: QuestionID; answers: Answer[] }) { + return runPromiseInstance(S.Service.use((s) => s.reply(input))) } - export async function reject(requestID: QuestionID): Promise { - return runPromiseInstance(Service.use((svc) => svc.reject(requestID))) + export async function reject(requestID: QuestionID) { + return runPromiseInstance(S.Service.use((s) => s.reject(requestID))) } - export async function list(): Promise { - return runPromiseInstance(Service.use((svc) => svc.list())) + export async function list() { + return runPromiseInstance(S.Service.use((s) => s.list())) } } diff --git a/packages/opencode/src/question/service.ts b/packages/opencode/src/question/service.ts new file mode 100644 index 000000000..a23703e97 --- /dev/null +++ b/packages/opencode/src/question/service.ts @@ -0,0 +1,172 @@ +import { Deferred, Effect, Layer, Schema, ServiceMap } from "effect" +import { Bus } from "@/bus" +import { BusEvent } from "@/bus/bus-event" +import { SessionID, MessageID } from "@/session/schema" +import { Log } from "@/util/log" +import z from "zod" +import { QuestionID } from "./schema" + +const log = Log.create({ service: "question" }) + +export namespace Question { + // Schemas + + export const Option = z + .object({ + label: z.string().describe("Display text (1-5 words, concise)"), + description: z.string().describe("Explanation of choice"), + }) + .meta({ ref: "QuestionOption" }) + export type Option = z.infer + + export const Info = z + .object({ + question: z.string().describe("Complete question"), + header: z.string().describe("Very short label (max 30 chars)"), + options: z.array(Option).describe("Available choices"), + multiple: z.boolean().optional().describe("Allow selecting multiple choices"), + custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"), + }) + .meta({ ref: "QuestionInfo" }) + export type Info = z.infer + + export const Request = z + .object({ + id: QuestionID.zod, + sessionID: SessionID.zod, + questions: z.array(Info).describe("Questions to ask"), + tool: z + .object({ + messageID: MessageID.zod, + callID: z.string(), + }) + .optional(), + }) + .meta({ ref: "QuestionRequest" }) + export type Request = z.infer + + export const Answer = z.array(z.string()).meta({ ref: "QuestionAnswer" }) + export type Answer = z.infer + + export const Reply = z.object({ + answers: z + .array(Answer) + .describe("User answers in order of questions (each answer is an array of selected labels)"), + }) + export type Reply = z.infer + + export const Event = { + Asked: BusEvent.define("question.asked", Request), + Replied: BusEvent.define( + "question.replied", + z.object({ + sessionID: SessionID.zod, + requestID: QuestionID.zod, + answers: z.array(Answer), + }), + ), + Rejected: BusEvent.define( + "question.rejected", + z.object({ + sessionID: SessionID.zod, + requestID: QuestionID.zod, + }), + ), + } + + export class RejectedError extends Schema.TaggedErrorClass()("QuestionRejectedError", {}) { + override get message() { + return "The user dismissed this question" + } + } + + interface PendingEntry { + info: Request + deferred: Deferred.Deferred + } + + // Service + + export interface Interface { + readonly ask: (input: { + sessionID: SessionID + questions: Info[] + tool?: { messageID: MessageID; callID: string } + }) => Effect.Effect + readonly reply: (input: { requestID: QuestionID; answers: Answer[] }) => Effect.Effect + readonly reject: (requestID: QuestionID) => Effect.Effect + readonly list: () => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/Question") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const pending = new Map() + + const ask = Effect.fn("Question.ask")(function* (input: { + sessionID: SessionID + questions: Info[] + tool?: { messageID: MessageID; callID: string } + }) { + const id = QuestionID.ascending() + log.info("asking", { id, questions: input.questions.length }) + + const deferred = yield* Deferred.make() + const info: Request = { + id, + sessionID: input.sessionID, + questions: input.questions, + tool: input.tool, + } + pending.set(id, { info, deferred }) + Bus.publish(Event.Asked, info) + + return yield* Effect.ensuring( + Deferred.await(deferred), + Effect.sync(() => { + pending.delete(id) + }), + ) + }) + + const reply = Effect.fn("Question.reply")(function* (input: { requestID: QuestionID; answers: Answer[] }) { + const existing = pending.get(input.requestID) + if (!existing) { + log.warn("reply for unknown request", { requestID: input.requestID }) + return + } + pending.delete(input.requestID) + log.info("replied", { requestID: input.requestID, answers: input.answers }) + Bus.publish(Event.Replied, { + sessionID: existing.info.sessionID, + requestID: existing.info.id, + answers: input.answers, + }) + yield* Deferred.succeed(existing.deferred, input.answers) + }) + + const reject = Effect.fn("Question.reject")(function* (requestID: QuestionID) { + const existing = pending.get(requestID) + if (!existing) { + log.warn("reject for unknown request", { requestID }) + return + } + pending.delete(requestID) + log.info("rejected", { requestID }) + Bus.publish(Event.Rejected, { + sessionID: existing.info.sessionID, + requestID: existing.info.id, + }) + yield* Deferred.fail(existing.deferred, new RejectedError()) + }) + + const list = Effect.fn("Question.list")(function* () { + return Array.from(pending.values(), (x) => x.info) + }) + + return Service.of({ ask, reply, reject, list }) + }), + ).pipe(Layer.fresh) +} diff --git a/packages/opencode/src/skill/service.ts b/packages/opencode/src/skill/service.ts new file mode 100644 index 000000000..434a51bad --- /dev/null +++ b/packages/opencode/src/skill/service.ts @@ -0,0 +1,238 @@ +import os from "os" +import path from "path" +import { pathToFileURL } from "url" +import z from "zod" +import { Effect, Layer, ServiceMap } from "effect" +import { NamedError } from "@opencode-ai/util/error" +import type { Agent } from "@/agent/agent" +import { Bus } from "@/bus" +import { InstanceContext } from "@/effect/instance-context" +import { Flag } from "@/flag/flag" +import { Global } from "@/global" +import { Permission } from "@/permission/service" +import { Filesystem } from "@/util/filesystem" +import { Config } from "../config/config" +import { ConfigMarkdown } from "../config/markdown" +import { Glob } from "../util/glob" +import { Log } from "../util/log" +import { Discovery } from "./discovery" + +export namespace Skill { + const log = Log.create({ service: "skill" }) + const EXTERNAL_DIRS = [".claude", ".agents"] + const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md" + const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md" + const SKILL_PATTERN = "**/SKILL.md" + + export const Info = z.object({ + name: z.string(), + description: z.string(), + location: z.string(), + content: z.string(), + }) + export type Info = z.infer + + export const InvalidError = NamedError.create( + "SkillInvalidError", + z.object({ + path: z.string(), + message: z.string().optional(), + issues: z.custom().optional(), + }), + ) + + export const NameMismatchError = NamedError.create( + "SkillNameMismatchError", + z.object({ + path: z.string(), + expected: z.string(), + actual: z.string(), + }), + ) + + type State = { + skills: Record + dirs: Set + task?: Promise + } + + type Cache = State & { + ensure: () => Promise + } + + export interface Interface { + readonly get: (name: string) => Effect.Effect + readonly all: () => Effect.Effect + readonly dirs: () => Effect.Effect + readonly available: (agent?: Agent.Info) => Effect.Effect + } + + const add = async (state: State, match: string) => { + const md = await ConfigMarkdown.parse(match).catch(async (err) => { + const message = ConfigMarkdown.FrontmatterError.isInstance(err) + ? err.data.message + : `Failed to parse skill ${match}` + const { Session } = await import("@/session") + Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) + log.error("failed to load skill", { skill: match, err }) + return undefined + }) + + if (!md) return + + const parsed = Info.pick({ name: true, description: true }).safeParse(md.data) + if (!parsed.success) return + + if (state.skills[parsed.data.name]) { + log.warn("duplicate skill name", { + name: parsed.data.name, + existing: state.skills[parsed.data.name].location, + duplicate: match, + }) + } + + state.dirs.add(path.dirname(match)) + state.skills[parsed.data.name] = { + name: parsed.data.name, + description: parsed.data.description, + location: match, + content: md.content, + } + } + + const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => { + return Glob.scan(pattern, { + cwd: root, + absolute: true, + include: "file", + symlink: true, + dot: opts?.dot, + }) + .then((matches) => Promise.all(matches.map((match) => add(state, match)))) + .catch((error) => { + if (!opts?.scope) throw error + log.error(`failed to scan ${opts.scope} skills`, { dir: root, error }) + }) + } + + // TODO: Migrate to Effect + const create = (instance: InstanceContext.Shape, discovery: Discovery.Interface): Cache => { + const state: State = { + skills: {}, + dirs: new Set(), + } + + const load = async () => { + if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) { + for (const dir of EXTERNAL_DIRS) { + const root = path.join(Global.Path.home, dir) + if (!(await Filesystem.isDir(root))) continue + await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" }) + } + + for await (const root of Filesystem.up({ + targets: EXTERNAL_DIRS, + start: instance.directory, + stop: instance.project.worktree, + })) { + await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" }) + } + } + + for (const dir of await Config.directories()) { + await scan(state, dir, OPENCODE_SKILL_PATTERN) + } + + const cfg = await Config.get() + for (const item of cfg.skills?.paths ?? []) { + const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item + const dir = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded) + if (!(await Filesystem.isDir(dir))) { + log.warn("skill path not found", { path: dir }) + continue + } + + await scan(state, dir, SKILL_PATTERN) + } + + for (const url of cfg.skills?.urls ?? []) { + for (const dir of await Effect.runPromise(discovery.pull(url))) { + state.dirs.add(dir) + await scan(state, dir, SKILL_PATTERN) + } + } + + log.info("init", { count: Object.keys(state.skills).length }) + } + + const ensure = () => { + if (state.task) return state.task + state.task = load().catch((err) => { + state.task = undefined + throw err + }) + return state.task + } + + return { ...state, ensure } + } + + export class Service extends ServiceMap.Service()("@opencode/Skill") {} + + export const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const instance = yield* InstanceContext + const discovery = yield* Discovery.Service + const state = create(instance, discovery) + + const get = Effect.fn("Skill.get")(function* (name: string) { + yield* Effect.promise(() => state.ensure()) + return state.skills[name] + }) + + const all = Effect.fn("Skill.all")(function* () { + yield* Effect.promise(() => state.ensure()) + return Object.values(state.skills) + }) + + const dirs = Effect.fn("Skill.dirs")(function* () { + yield* Effect.promise(() => state.ensure()) + return Array.from(state.dirs) + }) + + const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) { + yield* Effect.promise(() => state.ensure()) + const list = Object.values(state.skills).toSorted((a, b) => a.name.localeCompare(b.name)) + if (!agent) return list + return list.filter((skill) => Permission.evaluate("skill", skill.name, agent.permission).action !== "deny") + }) + + return Service.of({ get, all, dirs, available }) + }), + ).pipe(Layer.fresh) + + export const defaultLayer: Layer.Layer = layer.pipe( + Layer.provide(Discovery.defaultLayer), + ) + + export function fmt(list: Info[], opts: { verbose: boolean }) { + if (list.length === 0) return "No skills are currently available." + + if (opts.verbose) { + return [ + "", + ...list.flatMap((skill) => [ + " ", + ` ${skill.name}`, + ` ${skill.description}`, + ` ${pathToFileURL(skill.location).href}`, + " ", + ]), + "", + ].join("\n") + } + + return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n") + } +} diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index 5339691a0..ed3e0a4b7 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -1,255 +1,35 @@ -import os from "os" -import path from "path" -import { pathToFileURL } from "url" -import z from "zod" -import { Effect, Layer, ServiceMap } from "effect" -import { NamedError } from "@opencode-ai/util/error" -import type { Agent } from "@/agent/agent" -import { Bus } from "@/bus" -import { InstanceContext } from "@/effect/instance-context" import { runPromiseInstance } from "@/effect/runtime" -import { Flag } from "@/flag/flag" -import { Global } from "@/global" -import { PermissionNext } from "@/permission" -import { Filesystem } from "@/util/filesystem" -import { Config } from "../config/config" -import { ConfigMarkdown } from "../config/markdown" -import { Glob } from "../util/glob" -import { Log } from "../util/log" -import { Discovery } from "./discovery" +import type { Agent } from "@/agent/agent" +import { Skill as S } from "./service" export namespace Skill { - const log = Log.create({ service: "skill" }) - const EXTERNAL_DIRS = [".claude", ".agents"] - const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md" - const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md" - const SKILL_PATTERN = "**/SKILL.md" + export const Info = S.Info + export type Info = S.Info - export const Info = z.object({ - name: z.string(), - description: z.string(), - location: z.string(), - content: z.string(), - }) - export type Info = z.infer + export const InvalidError = S.InvalidError + export const NameMismatchError = S.NameMismatchError - export const InvalidError = NamedError.create( - "SkillInvalidError", - z.object({ - path: z.string(), - message: z.string().optional(), - issues: z.custom().optional(), - }), - ) + export type Interface = S.Interface - export const NameMismatchError = NamedError.create( - "SkillNameMismatchError", - z.object({ - path: z.string(), - expected: z.string(), - actual: z.string(), - }), - ) + export const Service = S.Service + export const layer = S.layer + export const defaultLayer = S.defaultLayer - type State = { - skills: Record - dirs: Set - task?: Promise - } - - type Cache = State & { - ensure: () => Promise - } - - export interface Interface { - readonly get: (name: string) => Effect.Effect - readonly all: () => Effect.Effect - readonly dirs: () => Effect.Effect - readonly available: (agent?: Agent.Info) => Effect.Effect - } - - const add = async (state: State, match: string) => { - const md = await ConfigMarkdown.parse(match).catch(async (err) => { - const message = ConfigMarkdown.FrontmatterError.isInstance(err) - ? err.data.message - : `Failed to parse skill ${match}` - const { Session } = await import("@/session") - Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) - log.error("failed to load skill", { skill: match, err }) - return undefined - }) - - if (!md) return - - const parsed = Info.pick({ name: true, description: true }).safeParse(md.data) - if (!parsed.success) return - - if (state.skills[parsed.data.name]) { - log.warn("duplicate skill name", { - name: parsed.data.name, - existing: state.skills[parsed.data.name].location, - duplicate: match, - }) - } - - state.dirs.add(path.dirname(match)) - state.skills[parsed.data.name] = { - name: parsed.data.name, - description: parsed.data.description, - location: match, - content: md.content, - } - } - - const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => { - return Glob.scan(pattern, { - cwd: root, - absolute: true, - include: "file", - symlink: true, - dot: opts?.dot, - }) - .then((matches) => Promise.all(matches.map((match) => add(state, match)))) - .catch((error) => { - if (!opts?.scope) throw error - log.error(`failed to scan ${opts.scope} skills`, { dir: root, error }) - }) - } - - // TODO: Migrate to Effect - const create = (instance: InstanceContext.Shape, discovery: Discovery.Interface): Cache => { - const state: State = { - skills: {}, - dirs: new Set(), - } - - const load = async () => { - if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) { - for (const dir of EXTERNAL_DIRS) { - const root = path.join(Global.Path.home, dir) - if (!(await Filesystem.isDir(root))) continue - await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" }) - } - - for await (const root of Filesystem.up({ - targets: EXTERNAL_DIRS, - start: instance.directory, - stop: instance.project.worktree, - })) { - await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" }) - } - } - - for (const dir of await Config.directories()) { - await scan(state, dir, OPENCODE_SKILL_PATTERN) - } - - const cfg = await Config.get() - for (const item of cfg.skills?.paths ?? []) { - const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item - const dir = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded) - if (!(await Filesystem.isDir(dir))) { - log.warn("skill path not found", { path: dir }) - continue - } - - await scan(state, dir, SKILL_PATTERN) - } - - for (const url of cfg.skills?.urls ?? []) { - for (const dir of await Effect.runPromise(discovery.pull(url))) { - state.dirs.add(dir) - await scan(state, dir, SKILL_PATTERN) - } - } - - log.info("init", { count: Object.keys(state.skills).length }) - } - - const ensure = () => { - if (state.task) return state.task - state.task = load().catch((err) => { - state.task = undefined - throw err - }) - return state.task - } - - return { ...state, ensure } - } - - export class Service extends ServiceMap.Service()("@opencode/Skill") {} - - export const layer: Layer.Layer = Layer.effect( - Service, - Effect.gen(function* () { - const instance = yield* InstanceContext - const discovery = yield* Discovery.Service - const state = create(instance, discovery) - - const get = Effect.fn("Skill.get")(function* (name: string) { - yield* Effect.promise(() => state.ensure()) - return state.skills[name] - }) - - const all = Effect.fn("Skill.all")(function* () { - yield* Effect.promise(() => state.ensure()) - return Object.values(state.skills) - }) - - const dirs = Effect.fn("Skill.dirs")(function* () { - yield* Effect.promise(() => state.ensure()) - return Array.from(state.dirs) - }) - - const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) { - yield* Effect.promise(() => state.ensure()) - const list = Object.values(state.skills).toSorted((a, b) => a.name.localeCompare(b.name)) - if (!agent) return list - return list.filter((skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny") - }) - - return Service.of({ get, all, dirs, available }) - }), - ) - - export const defaultLayer: Layer.Layer = layer.pipe( - Layer.provide(Discovery.defaultLayer), - ) + export const fmt = S.fmt export async function get(name: string) { - return runPromiseInstance(Service.use((skill) => skill.get(name))) + return runPromiseInstance(S.Service.use((skill) => skill.get(name))) } export async function all() { - return runPromiseInstance(Service.use((skill) => skill.all())) + return runPromiseInstance(S.Service.use((skill) => skill.all())) } export async function dirs() { - return runPromiseInstance(Service.use((skill) => skill.dirs())) + return runPromiseInstance(S.Service.use((skill) => skill.dirs())) } export async function available(agent?: Agent.Info) { - return runPromiseInstance(Service.use((skill) => skill.available(agent))) - } - - export function fmt(list: Info[], opts: { verbose: boolean }) { - if (list.length === 0) return "No skills are currently available." - - if (opts.verbose) { - return [ - "", - ...list.flatMap((skill) => [ - " ", - ` ${skill.name}`, - ` ${skill.description}`, - ` ${pathToFileURL(skill.location).href}`, - " ", - ]), - "", - ].join("\n") - } - - return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n") + return runPromiseInstance(S.Service.use((skill) => skill.available(agent))) } } diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 887bce334..4f845ca2d 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -1,349 +1,44 @@ -import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node" -import { Cause, Duration, Effect, Layer, Schedule, ServiceMap, Stream } from "effect" -import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" -import path from "path" -import z from "zod" -import { InstanceContext } from "@/effect/instance-context" import { runPromiseInstance } from "@/effect/runtime" -import { AppFileSystem } from "@/filesystem" -import { Config } from "../config/config" -import { Global } from "../global" -import { Log } from "../util/log" +import { Snapshot as S } from "./service" export namespace Snapshot { - export const Patch = z.object({ - hash: z.string(), - files: z.string().array(), - }) - export type Patch = z.infer + export const Patch = S.Patch + export type Patch = S.Patch - export const FileDiff = z - .object({ - file: z.string(), - before: z.string(), - after: z.string(), - additions: z.number(), - deletions: z.number(), - status: z.enum(["added", "deleted", "modified"]).optional(), - }) - .meta({ - ref: "FileDiff", - }) - export type FileDiff = z.infer + export const FileDiff = S.FileDiff + export type FileDiff = S.FileDiff + + export type Interface = S.Interface + + export const Service = S.Service + export const layer = S.layer + export const defaultLayer = S.defaultLayer export async function cleanup() { - return runPromiseInstance(Service.use((svc) => svc.cleanup())) + return runPromiseInstance(S.Service.use((svc) => svc.cleanup())) } export async function track() { - return runPromiseInstance(Service.use((svc) => svc.track())) + return runPromiseInstance(S.Service.use((svc) => svc.track())) } export async function patch(hash: string) { - return runPromiseInstance(Service.use((svc) => svc.patch(hash))) + return runPromiseInstance(S.Service.use((svc) => svc.patch(hash))) } export async function restore(snapshot: string) { - return runPromiseInstance(Service.use((svc) => svc.restore(snapshot))) + return runPromiseInstance(S.Service.use((svc) => svc.restore(snapshot))) } export async function revert(patches: Patch[]) { - return runPromiseInstance(Service.use((svc) => svc.revert(patches))) + return runPromiseInstance(S.Service.use((svc) => svc.revert(patches))) } export async function diff(hash: string) { - return runPromiseInstance(Service.use((svc) => svc.diff(hash))) + return runPromiseInstance(S.Service.use((svc) => svc.diff(hash))) } export async function diffFull(from: string, to: string) { - return runPromiseInstance(Service.use((svc) => svc.diffFull(from, to))) + return runPromiseInstance(S.Service.use((svc) => svc.diffFull(from, to))) } - - const log = Log.create({ service: "snapshot" }) - const prune = "7.days" - const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"] - const cfg = ["-c", "core.autocrlf=false", ...core] - const quote = [...cfg, "-c", "core.quotepath=false"] - - interface GitResult { - readonly code: ChildProcessSpawner.ExitCode - readonly text: string - readonly stderr: string - } - - export interface Interface { - readonly cleanup: () => Effect.Effect - readonly track: () => Effect.Effect - readonly patch: (hash: string) => Effect.Effect - readonly restore: (snapshot: string) => Effect.Effect - readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect - readonly diff: (hash: string) => Effect.Effect - readonly diffFull: (from: string, to: string) => Effect.Effect - } - - export class Service extends ServiceMap.Service()("@opencode/Snapshot") {} - - export const layer: Layer.Layer< - Service, - never, - InstanceContext | AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner - > = Layer.effect( - Service, - Effect.gen(function* () { - const ctx = yield* InstanceContext - const fs = yield* AppFileSystem.Service - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner - const directory = ctx.directory - const worktree = ctx.worktree - const project = ctx.project - const gitdir = path.join(Global.Path.data, "snapshot", project.id) - - const args = (cmd: string[]) => ["--git-dir", gitdir, "--work-tree", worktree, ...cmd] - - const git = Effect.fnUntraced( - function* (cmd: string[], opts?: { cwd?: string; env?: Record }) { - const proc = ChildProcess.make("git", cmd, { - cwd: opts?.cwd, - env: opts?.env, - extendEnv: true, - }) - const handle = yield* spawner.spawn(proc) - const [text, stderr] = yield* Effect.all( - [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], - { concurrency: 2 }, - ) - const code = yield* handle.exitCode - return { code, text, stderr } satisfies GitResult - }, - Effect.scoped, - Effect.catch((err) => - Effect.succeed({ - code: ChildProcessSpawner.ExitCode(1), - text: "", - stderr: String(err), - }), - ), - ) - - // Snapshot-specific error handling on top of AppFileSystem - const exists = (file: string) => fs.exists(file).pipe(Effect.orDie) - const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(""))) - const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void)) - - const enabled = Effect.fnUntraced(function* () { - if (project.vcs !== "git") return false - return (yield* Effect.promise(() => Config.get())).snapshot !== false - }) - - const excludes = Effect.fnUntraced(function* () { - const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], { - cwd: worktree, - }) - const file = result.text.trim() - if (!file) return - if (!(yield* exists(file))) return - return file - }) - - const sync = Effect.fnUntraced(function* () { - const file = yield* excludes() - const target = path.join(gitdir, "info", "exclude") - yield* fs.ensureDir(path.join(gitdir, "info")).pipe(Effect.orDie) - if (!file) { - yield* fs.writeFileString(target, "").pipe(Effect.orDie) - return - } - yield* fs.writeFileString(target, yield* read(file)).pipe(Effect.orDie) - }) - - const add = Effect.fnUntraced(function* () { - yield* sync() - yield* git([...cfg, ...args(["add", "."])], { cwd: directory }) - }) - - const cleanup = Effect.fn("Snapshot.cleanup")(function* () { - if (!(yield* enabled())) return - if (!(yield* exists(gitdir))) return - const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: directory }) - if (result.code !== 0) { - log.warn("cleanup failed", { - exitCode: result.code, - stderr: result.stderr, - }) - return - } - log.info("cleanup", { prune }) - }) - - const track = Effect.fn("Snapshot.track")(function* () { - if (!(yield* enabled())) return - const existed = yield* exists(gitdir) - yield* fs.ensureDir(gitdir).pipe(Effect.orDie) - if (!existed) { - yield* git(["init"], { - env: { GIT_DIR: gitdir, GIT_WORK_TREE: worktree }, - }) - yield* git(["--git-dir", gitdir, "config", "core.autocrlf", "false"]) - yield* git(["--git-dir", gitdir, "config", "core.longpaths", "true"]) - yield* git(["--git-dir", gitdir, "config", "core.symlinks", "true"]) - yield* git(["--git-dir", gitdir, "config", "core.fsmonitor", "false"]) - log.info("initialized") - } - yield* add() - const result = yield* git(args(["write-tree"]), { cwd: directory }) - const hash = result.text.trim() - log.info("tracking", { hash, cwd: directory, git: gitdir }) - return hash - }) - - const patch = Effect.fn("Snapshot.patch")(function* (hash: string) { - yield* add() - const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])], { - cwd: directory, - }) - if (result.code !== 0) { - log.warn("failed to get diff", { hash, exitCode: result.code }) - return { hash, files: [] } - } - return { - hash, - files: result.text - .trim() - .split("\n") - .map((x) => x.trim()) - .filter(Boolean) - .map((x) => path.join(worktree, x).replaceAll("\\", "/")), - } - }) - - const restore = Effect.fn("Snapshot.restore")(function* (snapshot: string) { - log.info("restore", { commit: snapshot }) - const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: worktree }) - if (result.code === 0) { - const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { cwd: worktree }) - if (checkout.code === 0) return - log.error("failed to restore snapshot", { - snapshot, - exitCode: checkout.code, - stderr: checkout.stderr, - }) - return - } - log.error("failed to restore snapshot", { - snapshot, - exitCode: result.code, - stderr: result.stderr, - }) - }) - - const revert = Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) { - const seen = new Set() - for (const item of patches) { - for (const file of item.files) { - if (seen.has(file)) continue - seen.add(file) - log.info("reverting", { file, hash: item.hash }) - const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], { cwd: worktree }) - if (result.code !== 0) { - const rel = path.relative(worktree, file) - const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], { cwd: worktree }) - if (tree.code === 0 && tree.text.trim()) { - log.info("file existed in snapshot but checkout failed, keeping", { file }) - } else { - log.info("file did not exist in snapshot, deleting", { file }) - yield* remove(file) - } - } - } - } - }) - - const diff = Effect.fn("Snapshot.diff")(function* (hash: string) { - yield* add() - const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", hash, "--", "."])], { - cwd: worktree, - }) - if (result.code !== 0) { - log.warn("failed to get diff", { - hash, - exitCode: result.code, - stderr: result.stderr, - }) - return "" - } - return result.text.trim() - }) - - const diffFull = Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) { - const result: Snapshot.FileDiff[] = [] - const status = new Map() - - const statuses = yield* git( - [...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])], - { cwd: directory }, - ) - - for (const line of statuses.text.trim().split("\n")) { - if (!line) continue - const [code, file] = line.split("\t") - if (!code || !file) continue - status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified") - } - - const numstat = yield* git( - [...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])], - { - cwd: directory, - }, - ) - - for (const line of numstat.text.trim().split("\n")) { - if (!line) continue - const [adds, dels, file] = line.split("\t") - if (!file) continue - const binary = adds === "-" && dels === "-" - const [before, after] = binary - ? ["", ""] - : yield* Effect.all( - [ - git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)), - git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)), - ], - { concurrency: 2 }, - ) - const additions = binary ? 0 : parseInt(adds) - const deletions = binary ? 0 : parseInt(dels) - result.push({ - file, - before, - after, - additions: Number.isFinite(additions) ? additions : 0, - deletions: Number.isFinite(deletions) ? deletions : 0, - status: status.get(file) ?? "modified", - }) - } - - return result - }) - - yield* cleanup().pipe( - Effect.catchCause((cause) => { - log.error("cleanup loop failed", { cause: Cause.pretty(cause) }) - return Effect.void - }), - Effect.repeat(Schedule.spaced(Duration.hours(1))), - Effect.delay(Duration.minutes(1)), - Effect.forkScoped, - ) - - return Service.of({ cleanup, track, patch, restore, revert, diff, diffFull }) - }), - ) - - export const defaultLayer = layer.pipe( - Layer.provide(NodeChildProcessSpawner.layer), - Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(NodeFileSystem.layer), // needed by NodeChildProcessSpawner - Layer.provide(NodePath.layer), - ) } diff --git a/packages/opencode/src/snapshot/service.ts b/packages/opencode/src/snapshot/service.ts new file mode 100644 index 000000000..50485d0a7 --- /dev/null +++ b/packages/opencode/src/snapshot/service.ts @@ -0,0 +1,320 @@ +import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node" +import { Cause, Duration, Effect, Layer, Schedule, ServiceMap, Stream } from "effect" +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" +import path from "path" +import z from "zod" +import { InstanceContext } from "@/effect/instance-context" +import { AppFileSystem } from "@/filesystem" +import { Config } from "../config/config" +import { Global } from "../global" +import { Log } from "../util/log" + +export namespace Snapshot { + export const Patch = z.object({ + hash: z.string(), + files: z.string().array(), + }) + export type Patch = z.infer + + export const FileDiff = z + .object({ + file: z.string(), + before: z.string(), + after: z.string(), + additions: z.number(), + deletions: z.number(), + status: z.enum(["added", "deleted", "modified"]).optional(), + }) + .meta({ + ref: "FileDiff", + }) + export type FileDiff = z.infer + + const log = Log.create({ service: "snapshot" }) + const prune = "7.days" + const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"] + const cfg = ["-c", "core.autocrlf=false", ...core] + const quote = [...cfg, "-c", "core.quotepath=false"] + + interface GitResult { + readonly code: ChildProcessSpawner.ExitCode + readonly text: string + readonly stderr: string + } + + export interface Interface { + readonly cleanup: () => Effect.Effect + readonly track: () => Effect.Effect + readonly patch: (hash: string) => Effect.Effect + readonly restore: (snapshot: string) => Effect.Effect + readonly revert: (patches: Snapshot.Patch[]) => Effect.Effect + readonly diff: (hash: string) => Effect.Effect + readonly diffFull: (from: string, to: string) => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@opencode/Snapshot") {} + + export const layer: Layer.Layer< + Service, + never, + InstanceContext | AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner + > = Layer.effect( + Service, + Effect.gen(function* () { + const ctx = yield* InstanceContext + const fs = yield* AppFileSystem.Service + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + const directory = ctx.directory + const worktree = ctx.worktree + const project = ctx.project + const gitdir = path.join(Global.Path.data, "snapshot", project.id) + + const args = (cmd: string[]) => ["--git-dir", gitdir, "--work-tree", worktree, ...cmd] + + const git = Effect.fnUntraced( + function* (cmd: string[], opts?: { cwd?: string; env?: Record }) { + const proc = ChildProcess.make("git", cmd, { + cwd: opts?.cwd, + env: opts?.env, + extendEnv: true, + }) + const handle = yield* spawner.spawn(proc) + const [text, stderr] = yield* Effect.all( + [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], + { concurrency: 2 }, + ) + const code = yield* handle.exitCode + return { code, text, stderr } satisfies GitResult + }, + Effect.scoped, + Effect.catch((err) => + Effect.succeed({ + code: ChildProcessSpawner.ExitCode(1), + text: "", + stderr: String(err), + }), + ), + ) + + // Snapshot-specific error handling on top of AppFileSystem + const exists = (file: string) => fs.exists(file).pipe(Effect.orDie) + const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed(""))) + const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void)) + + const enabled = Effect.fnUntraced(function* () { + if (project.vcs !== "git") return false + return (yield* Effect.promise(() => Config.get())).snapshot !== false + }) + + const excludes = Effect.fnUntraced(function* () { + const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], { + cwd: worktree, + }) + const file = result.text.trim() + if (!file) return + if (!(yield* exists(file))) return + return file + }) + + const sync = Effect.fnUntraced(function* () { + const file = yield* excludes() + const target = path.join(gitdir, "info", "exclude") + yield* fs.ensureDir(path.join(gitdir, "info")).pipe(Effect.orDie) + if (!file) { + yield* fs.writeFileString(target, "").pipe(Effect.orDie) + return + } + yield* fs.writeFileString(target, yield* read(file)).pipe(Effect.orDie) + }) + + const add = Effect.fnUntraced(function* () { + yield* sync() + yield* git([...cfg, ...args(["add", "."])], { cwd: directory }) + }) + + const cleanup = Effect.fn("Snapshot.cleanup")(function* () { + if (!(yield* enabled())) return + if (!(yield* exists(gitdir))) return + const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: directory }) + if (result.code !== 0) { + log.warn("cleanup failed", { + exitCode: result.code, + stderr: result.stderr, + }) + return + } + log.info("cleanup", { prune }) + }) + + const track = Effect.fn("Snapshot.track")(function* () { + if (!(yield* enabled())) return + const existed = yield* exists(gitdir) + yield* fs.ensureDir(gitdir).pipe(Effect.orDie) + if (!existed) { + yield* git(["init"], { + env: { GIT_DIR: gitdir, GIT_WORK_TREE: worktree }, + }) + yield* git(["--git-dir", gitdir, "config", "core.autocrlf", "false"]) + yield* git(["--git-dir", gitdir, "config", "core.longpaths", "true"]) + yield* git(["--git-dir", gitdir, "config", "core.symlinks", "true"]) + yield* git(["--git-dir", gitdir, "config", "core.fsmonitor", "false"]) + log.info("initialized") + } + yield* add() + const result = yield* git(args(["write-tree"]), { cwd: directory }) + const hash = result.text.trim() + log.info("tracking", { hash, cwd: directory, git: gitdir }) + return hash + }) + + const patch = Effect.fn("Snapshot.patch")(function* (hash: string) { + yield* add() + const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])], { + cwd: directory, + }) + if (result.code !== 0) { + log.warn("failed to get diff", { hash, exitCode: result.code }) + return { hash, files: [] } + } + return { + hash, + files: result.text + .trim() + .split("\n") + .map((x) => x.trim()) + .filter(Boolean) + .map((x) => path.join(worktree, x).replaceAll("\\", "/")), + } + }) + + const restore = Effect.fn("Snapshot.restore")(function* (snapshot: string) { + log.info("restore", { commit: snapshot }) + const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: worktree }) + if (result.code === 0) { + const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { cwd: worktree }) + if (checkout.code === 0) return + log.error("failed to restore snapshot", { + snapshot, + exitCode: checkout.code, + stderr: checkout.stderr, + }) + return + } + log.error("failed to restore snapshot", { + snapshot, + exitCode: result.code, + stderr: result.stderr, + }) + }) + + const revert = Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) { + const seen = new Set() + for (const item of patches) { + for (const file of item.files) { + if (seen.has(file)) continue + seen.add(file) + log.info("reverting", { file, hash: item.hash }) + const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], { cwd: worktree }) + if (result.code !== 0) { + const rel = path.relative(worktree, file) + const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], { cwd: worktree }) + if (tree.code === 0 && tree.text.trim()) { + log.info("file existed in snapshot but checkout failed, keeping", { file }) + } else { + log.info("file did not exist in snapshot, deleting", { file }) + yield* remove(file) + } + } + } + } + }) + + const diff = Effect.fn("Snapshot.diff")(function* (hash: string) { + yield* add() + const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", hash, "--", "."])], { + cwd: worktree, + }) + if (result.code !== 0) { + log.warn("failed to get diff", { + hash, + exitCode: result.code, + stderr: result.stderr, + }) + return "" + } + return result.text.trim() + }) + + const diffFull = Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) { + const result: Snapshot.FileDiff[] = [] + const status = new Map() + + const statuses = yield* git( + [...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])], + { cwd: directory }, + ) + + for (const line of statuses.text.trim().split("\n")) { + if (!line) continue + const [code, file] = line.split("\t") + if (!code || !file) continue + status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified") + } + + const numstat = yield* git( + [...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])], + { + cwd: directory, + }, + ) + + for (const line of numstat.text.trim().split("\n")) { + if (!line) continue + const [adds, dels, file] = line.split("\t") + if (!file) continue + const binary = adds === "-" && dels === "-" + const [before, after] = binary + ? ["", ""] + : yield* Effect.all( + [ + git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)), + git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)), + ], + { concurrency: 2 }, + ) + const additions = binary ? 0 : parseInt(adds) + const deletions = binary ? 0 : parseInt(dels) + result.push({ + file, + before, + after, + additions: Number.isFinite(additions) ? additions : 0, + deletions: Number.isFinite(deletions) ? deletions : 0, + status: status.get(file) ?? "modified", + }) + } + + return result + }) + + yield* cleanup().pipe( + Effect.catchCause((cause) => { + log.error("cleanup loop failed", { cause: Cause.pretty(cause) }) + return Effect.void + }), + Effect.repeat(Schedule.spaced(Duration.hours(1))), + Effect.delay(Duration.minutes(1)), + Effect.forkScoped, + ) + + return Service.of({ cleanup, track, patch, restore, revert, diff, diffFull }) + }), + ).pipe(Layer.fresh) + + export const defaultLayer = layer.pipe( + Layer.provide(NodeChildProcessSpawner.layer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(NodeFileSystem.layer), // needed by NodeChildProcessSpawner + Layer.provide(NodePath.layer), + ) +} diff --git a/packages/opencode/test/effect/runtime.test.ts b/packages/opencode/test/effect/runtime.test.ts new file mode 100644 index 000000000..70bf29aaf --- /dev/null +++ b/packages/opencode/test/effect/runtime.test.ts @@ -0,0 +1,128 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { runtime, runPromiseInstance } from "../../src/effect/runtime" +import { Auth } from "../../src/auth/effect" +import { Instances } from "../../src/effect/instances" +import { Instance } from "../../src/project/instance" +import { ProviderAuth } from "../../src/provider/auth" +import { Vcs } from "../../src/project/vcs" +import { Question } from "../../src/question" +import { tmpdir } from "../fixture/fixture" + +/** + * Integration tests for the Effect runtime and LayerMap-based instance system. + * + * Each instance service layer has `.pipe(Layer.fresh)` at its definition site + * so it is always rebuilt per directory, while shared dependencies are provided + * outside the fresh boundary and remain memoizable. + * + * These tests verify the invariants using object identity (===) on the real + * production services — not mock services or return-value checks. + */ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const grabInstance = (service: any) => runPromiseInstance(service.use(Effect.succeed)) +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const grabGlobal = (service: any) => runtime.runPromise(service.use(Effect.succeed)) + +describe("effect/runtime", () => { + afterEach(async () => { + await Instance.disposeAll() + }) + + test("global services are shared across directories", async () => { + await using one = await tmpdir({ git: true }) + await using two = await tmpdir({ git: true }) + + // Auth is a global service — it should be the exact same object + // regardless of which directory we're in. + const authOne = await Instance.provide({ + directory: one.path, + fn: () => grabGlobal(Auth.Service), + }) + + const authTwo = await Instance.provide({ + directory: two.path, + fn: () => grabGlobal(Auth.Service), + }) + + expect(authOne).toBe(authTwo) + }) + + test("instance services with global deps share the global (ProviderAuth → Auth)", async () => { + await using one = await tmpdir({ git: true }) + await using two = await tmpdir({ git: true }) + + // ProviderAuth depends on Auth via defaultLayer. + // The instance service itself should be different per directory, + // but the underlying Auth should be shared. + const paOne = await Instance.provide({ + directory: one.path, + fn: () => grabInstance(ProviderAuth.Service), + }) + + const paTwo = await Instance.provide({ + directory: two.path, + fn: () => grabInstance(ProviderAuth.Service), + }) + + // Different directories → different ProviderAuth instances. + expect(paOne).not.toBe(paTwo) + + // But the global Auth is the same object in both. + const authOne = await Instance.provide({ + directory: one.path, + fn: () => grabGlobal(Auth.Service), + }) + const authTwo = await Instance.provide({ + directory: two.path, + fn: () => grabGlobal(Auth.Service), + }) + expect(authOne).toBe(authTwo) + }) + + test("instance services are shared within the same directory", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + expect(await grabInstance(Vcs.Service)).toBe(await grabInstance(Vcs.Service)) + expect(await grabInstance(Question.Service)).toBe(await grabInstance(Question.Service)) + }, + }) + }) + + test("different directories get different service instances", async () => { + await using one = await tmpdir({ git: true }) + await using two = await tmpdir({ git: true }) + + const vcsOne = await Instance.provide({ + directory: one.path, + fn: () => grabInstance(Vcs.Service), + }) + + const vcsTwo = await Instance.provide({ + directory: two.path, + fn: () => grabInstance(Vcs.Service), + }) + + expect(vcsOne).not.toBe(vcsTwo) + }) + + test("disposal rebuilds services with a new instance", async () => { + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const before = await grabInstance(Question.Service) + + await runtime.runPromise(Instances.use((map) => map.invalidate(Instance.directory))) + + const after = await grabInstance(Question.Service) + expect(after).not.toBe(before) + }, + }) + }) +}) diff --git a/packages/opencode/test/fixture/instance.ts b/packages/opencode/test/fixture/instance.ts index ce880d70d..67af82fc8 100644 --- a/packages/opencode/test/fixture/instance.ts +++ b/packages/opencode/test/fixture/instance.ts @@ -34,7 +34,7 @@ export function withServices( project: Instance.project, }), ) - let resolved: Layer.Layer = Layer.fresh(layer).pipe(Layer.provide(ctx)) as any + let resolved: Layer.Layer = layer.pipe(Layer.provide(ctx)) as any if (options?.provide) { for (const l of options.provide) { resolved = resolved.pipe(Layer.provide(l)) as any