refactor(effect): unify service namespaces for file, format, vcs, skill, snapshot

Collapse the two-namespace pattern (e.g. File + FileService) into a single
namespace per module: Interface, Service, layer, and defaultLayer all live on
the domain namespace directly. Rename DiscoveryService → Discovery for
consistency. Remove no-op init() methods and unnecessary defaultLayer = layer
re-exports per EFFECT_MIGRATION_PLAN.md conventions.
This commit is contained in:
Kit Langton 2026-03-17 22:05:34 -04:00
commit b2fa76ff7f
18 changed files with 1056 additions and 1169 deletions

View file

@ -1,15 +1,15 @@
import { Effect, Layer, LayerMap, ServiceMap } from "effect" import { Effect, Layer, LayerMap, ServiceMap } from "effect"
import { FileService } from "@/file" import { File } from "@/file"
import { FileTimeService } from "@/file/time" import { FileTime } from "@/file/time"
import { FileWatcherService } from "@/file/watcher" import { FileWatcher } from "@/file/watcher"
import { FormatService } from "@/format" import { Format } from "@/format"
import { PermissionEffect } from "@/permission/effect" import { PermissionEffect } from "@/permission/effect"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import { VcsService } from "@/project/vcs" import { Vcs } from "@/project/vcs"
import { ProviderAuthService } from "@/provider/auth-service" import { ProviderAuthEffect } from "@/provider/auth-effect"
import { QuestionEffect } from "@/question/effect" import { QuestionEffect } from "@/question/effect"
import { SkillService } from "@/skill/skill" import { Skill } from "@/skill/skill"
import { SnapshotService } from "@/snapshot" import { Snapshot } from "@/snapshot"
import { InstanceContext } from "./instance-context" import { InstanceContext } from "./instance-context"
import { registerDisposer } from "./instance-registry" import { registerDisposer } from "./instance-registry"
@ -18,14 +18,14 @@ export { InstanceContext } from "./instance-context"
export type InstanceServices = export type InstanceServices =
| QuestionEffect.Service | QuestionEffect.Service
| PermissionEffect.Service | PermissionEffect.Service
| ProviderAuthService | ProviderAuthEffect.Service
| FileWatcherService | FileWatcher.Service
| VcsService | Vcs.Service
| FileTimeService | FileTime.Service
| FormatService | Format.Service
| FileService | File.Service
| SkillService | Skill.Service
| SnapshotService | Snapshot.Service
// NOTE: LayerMap only passes the key (directory string) to lookup, but we need // NOTE: LayerMap only passes the key (directory string) to lookup, but we need
// the full instance context (directory, worktree, project). We read from the // the full instance context (directory, worktree, project). We read from the
@ -38,14 +38,14 @@ function lookup(_key: string) {
return Layer.mergeAll( return Layer.mergeAll(
Layer.fresh(QuestionEffect.layer), Layer.fresh(QuestionEffect.layer),
Layer.fresh(PermissionEffect.layer), Layer.fresh(PermissionEffect.layer),
Layer.fresh(ProviderAuthService.layer), Layer.fresh(ProviderAuthEffect.defaultLayer),
Layer.fresh(FileWatcherService.layer).pipe(Layer.orDie), Layer.fresh(FileWatcher.layer).pipe(Layer.orDie),
Layer.fresh(VcsService.layer), Layer.fresh(Vcs.layer),
Layer.fresh(FileTimeService.layer).pipe(Layer.orDie), Layer.fresh(FileTime.layer).pipe(Layer.orDie),
Layer.fresh(FormatService.layer), Layer.fresh(Format.layer),
Layer.fresh(FileService.layer), Layer.fresh(File.layer),
Layer.fresh(SkillService.layer), Layer.fresh(Skill.defaultLayer),
Layer.fresh(SnapshotService.layer), Layer.fresh(Snapshot.defaultLayer),
).pipe(Layer.provide(ctx)) ).pipe(Layer.provide(ctx))
} }

View file

@ -1,272 +1,20 @@
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import z from "zod"
import { formatPatch, structuredPatch } from "diff"
import path from "path"
import fs from "fs"
import ignore from "ignore"
import { Log } from "../util/log"
import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { Ripgrep } from "./ripgrep"
import fuzzysort from "fuzzysort"
import { Global } from "../global"
import { git } from "@/util/git"
import { Protected } from "./protected"
import { InstanceContext } from "@/effect/instance-context" import { InstanceContext } from "@/effect/instance-context"
import { Effect, Layer, ServiceMap } from "effect"
import { runPromiseInstance } from "@/effect/runtime" import { runPromiseInstance } from "@/effect/runtime"
import { git } from "@/util/git"
const log = Log.create({ service: "file" }) import { Effect, Layer, ServiceMap } from "effect"
import { formatPatch, structuredPatch } from "diff"
const binaryExtensions = new Set([ import fs from "fs"
"exe", import fuzzysort from "fuzzysort"
"dll", import ignore from "ignore"
"pdb", import path from "path"
"bin", import z from "zod"
"so", import { Global } from "../global"
"dylib", import { Instance } from "../project/instance"
"o", import { Filesystem } from "../util/filesystem"
"a", import { Log } from "../util/log"
"lib", import { Protected } from "./protected"
"wav", import { Ripgrep } from "./ripgrep"
"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 imageExtensions = 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 textExtensions = 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 textNames = new Set([
"dockerfile",
"makefile",
".gitignore",
".gitattributes",
".editorconfig",
".npmrc",
".nvmrc",
".prettierrc",
".eslintrc",
])
function isImageByExtension(filepath: string): boolean {
const ext = path.extname(filepath).toLowerCase().slice(1)
return imageExtensions.has(ext)
}
function isTextByExtension(filepath: string): boolean {
const ext = path.extname(filepath).toLowerCase().slice(1)
return textExtensions.has(ext)
}
function isTextByName(filepath: string): boolean {
const name = path.basename(filepath).toLowerCase()
return textNames.has(name)
}
function getImageMimeType(filepath: string): string {
const ext = path.extname(filepath).toLowerCase().slice(1)
const mimeTypes: Record<string, string> = {
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",
}
return mimeTypes[ext] || "image/" + ext
}
function isBinaryByExtension(filepath: string): boolean {
const ext = path.extname(filepath).toLowerCase().slice(1)
return binaryExtensions.has(ext)
}
function isImage(mimeType: string): boolean {
return mimeType.startsWith("image/")
}
function shouldEncode(mimeType: string): boolean {
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 parts = type.split("/", 2)
const top = parts[0]
const tops = ["image", "audio", "video", "font", "model", "multipart"]
if (tops.includes(top)) return true
return false
}
export namespace File { export namespace File {
export const Info = z export const Info = z
@ -336,28 +84,270 @@ export namespace File {
} }
export function init() { export function init() {
return runPromiseInstance(FileService.use((s) => s.init())) return runPromiseInstance(Service.use((svc) => svc.init()))
} }
export async function status() { export async function status() {
return runPromiseInstance(FileService.use((s) => s.status())) return runPromiseInstance(Service.use((svc) => svc.status()))
} }
export async function read(file: string): Promise<Content> { export async function read(file: string): Promise<Content> {
return runPromiseInstance(FileService.use((s) => s.read(file))) return runPromiseInstance(Service.use((svc) => svc.read(file)))
} }
export async function list(dir?: string) { export async function list(dir?: string) {
return runPromiseInstance(FileService.use((s) => s.list(dir))) return runPromiseInstance(Service.use((svc) => svc.list(dir)))
} }
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) { export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
return runPromiseInstance(FileService.use((s) => s.search(input))) return runPromiseInstance(Service.use((svc) => svc.search(input)))
} }
}
export namespace FileService { const log = Log.create({ service: "file" })
export interface Service {
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<string, string> = {
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<void> readonly init: () => Effect.Effect<void>
readonly status: () => Effect.Effect<File.Info[]> readonly status: () => Effect.Effect<File.Info[]>
readonly read: (file: string) => Effect.Effect<File.Content> readonly read: (file: string) => Effect.Effect<File.Content>
@ -369,36 +359,29 @@ export namespace FileService {
type?: "file" | "directory" type?: "file" | "directory"
}) => Effect.Effect<string[]> }) => Effect.Effect<string[]>
} }
}
export class FileService extends ServiceMap.Service<FileService, FileService.Service>()("@opencode/File") { export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/File") {}
static readonly layer = Layer.effect(
FileService, export const layer = Layer.effect(
Service,
Effect.gen(function* () { Effect.gen(function* () {
const instance = yield* InstanceContext const instance = yield* InstanceContext
// File cache state
type Entry = { files: string[]; dirs: string[] }
let cache: Entry = { files: [], dirs: [] } let cache: Entry = { files: [], dirs: [] }
let task: Promise<void> | undefined let task: Promise<void> | undefined
const isGlobalHome = instance.directory === Global.Path.home && instance.project.id === "global" const isGlobalHome = instance.directory === Global.Path.home && instance.project.id === "global"
function kick() { function kick() {
if (task) return task if (task) return task
task = (async () => { task = (async () => {
// Disable scanning if in root of file system
if (instance.directory === path.parse(instance.directory).root) return if (instance.directory === path.parse(instance.directory).root) return
const next: Entry = { files: [], dirs: [] } const next: Entry = { files: [], dirs: [] }
try { try {
if (isGlobalHome) { if (isGlobalHome) {
const dirs = new Set<string>() const dirs = new Set<string>()
const protectedNames = Protected.names() const protectedNames = Protected.names()
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"]) const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name) const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name) const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
const top = await fs.promises const top = await fs.promises
.readdir(instance.directory, { withFileTypes: true }) .readdir(instance.directory, { withFileTypes: true })
.catch(() => [] as fs.Dirent[]) .catch(() => [] as fs.Dirent[])
@ -419,7 +402,7 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
next.dirs = Array.from(dirs).toSorted() next.dirs = Array.from(dirs).toSorted()
} else { } else {
const set = new Set<string>() const seen = new Set<string>()
for await (const file of Ripgrep.files({ cwd: instance.directory })) { for await (const file of Ripgrep.files({ cwd: instance.directory })) {
next.files.push(file) next.files.push(file)
let current = file let current = file
@ -428,8 +411,8 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
if (dir === ".") break if (dir === ".") break
if (dir === current) break if (dir === current) break
current = dir current = dir
if (set.has(dir)) continue if (seen.has(dir)) continue
set.add(dir) seen.add(dir)
next.dirs.push(dir + "/") next.dirs.push(dir + "/")
} }
} }
@ -447,11 +430,11 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
return cache return cache
} }
const init = Effect.fn("FileService.init")(function* () { const init = Effect.fn("File.init")(function* () {
yield* Effect.promise(() => kick()) yield* Effect.promise(() => kick())
}) })
const status = Effect.fn("FileService.status")(function* () { const status = Effect.fn("File.status")(function* () {
if (instance.project.vcs !== "git") return [] if (instance.project.vcs !== "git") return []
return yield* Effect.promise(async () => { return yield* Effect.promise(async () => {
@ -461,14 +444,13 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
}) })
).text() ).text()
const changedFiles: File.Info[] = [] const changed: File.Info[] = []
if (diffOutput.trim()) { if (diffOutput.trim()) {
const lines = diffOutput.trim().split("\n") for (const line of diffOutput.trim().split("\n")) {
for (const line of lines) { const [added, removed, file] = line.split("\t")
const [added, removed, filepath] = line.split("\t") changed.push({
changedFiles.push({ path: file,
path: filepath,
added: added === "-" ? 0 : parseInt(added, 10), added: added === "-" ? 0 : parseInt(added, 10),
removed: removed === "-" ? 0 : parseInt(removed, 10), removed: removed === "-" ? 0 : parseInt(removed, 10),
status: "modified", status: "modified",
@ -494,14 +476,12 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
).text() ).text()
if (untrackedOutput.trim()) { if (untrackedOutput.trim()) {
const untrackedFiles = untrackedOutput.trim().split("\n") for (const file of untrackedOutput.trim().split("\n")) {
for (const filepath of untrackedFiles) {
try { try {
const content = await Filesystem.readText(path.join(instance.directory, filepath)) const content = await Filesystem.readText(path.join(instance.directory, file))
const lines = content.split("\n").length changed.push({
changedFiles.push({ path: file,
path: filepath, added: content.split("\n").length,
added: lines,
removed: 0, removed: 0,
status: "added", status: "added",
}) })
@ -511,7 +491,6 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
} }
} }
// Get deleted files
const deletedOutput = ( const deletedOutput = (
await git( await git(
[ [
@ -531,50 +510,51 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
).text() ).text()
if (deletedOutput.trim()) { if (deletedOutput.trim()) {
const deletedFiles = deletedOutput.trim().split("\n") for (const file of deletedOutput.trim().split("\n")) {
for (const filepath of deletedFiles) { changed.push({
changedFiles.push({ path: file,
path: filepath,
added: 0, added: 0,
removed: 0, // Could get original line count but would require another git command removed: 0,
status: "deleted", status: "deleted",
}) })
} }
} }
return changedFiles.map((x) => { return changed.map((item) => {
const full = path.isAbsolute(x.path) ? x.path : path.join(instance.directory, x.path) const full = path.isAbsolute(item.path) ? item.path : path.join(instance.directory, item.path)
return { return {
...x, ...item,
path: path.relative(instance.directory, full), path: path.relative(instance.directory, full),
} }
}) })
}) })
}) })
const read = Effect.fn("FileService.read")(function* (file: string) { const read = Effect.fn("File.read")(function* (file: string) {
return yield* Effect.promise(async (): Promise<File.Content> => { return yield* Effect.promise(async (): Promise<File.Content> => {
using _ = log.time("read", { file }) using _ = log.time("read", { file })
const full = path.join(instance.directory, file) const full = path.join(instance.directory, file)
if (!Instance.containsPath(full)) { if (!Instance.containsPath(full)) {
throw new Error(`Access denied: path escapes project directory`) throw new Error("Access denied: path escapes project directory")
} }
// Fast path: check extension before any filesystem operations
if (isImageByExtension(file)) { if (isImageByExtension(file)) {
if (await Filesystem.exists(full)) { if (await Filesystem.exists(full)) {
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([])) const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
const content = buffer.toString("base64") return {
const mimeType = getImageMimeType(file) type: "text",
return { type: "text", content, mimeType, encoding: "base64" } content: buffer.toString("base64"),
mimeType: getImageMimeType(file),
encoding: "base64",
}
} }
return { type: "text", content: "" } return { type: "text", content: "" }
} }
const text = isTextByExtension(file) || isTextByName(file) const knownText = isTextByExtension(file) || isTextByName(file)
if (isBinaryByExtension(file) && !text) { if (isBinaryByExtension(file) && !knownText) {
return { type: "binary", content: "" } return { type: "binary", content: "" }
} }
@ -583,7 +563,7 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
} }
const mimeType = Filesystem.mimeType(full) const mimeType = Filesystem.mimeType(full)
const encode = text ? false : shouldEncode(mimeType) const encode = knownText ? false : shouldEncode(mimeType)
if (encode && !isImage(mimeType)) { if (encode && !isImage(mimeType)) {
return { type: "binary", content: "", mimeType } return { type: "binary", content: "", mimeType }
@ -591,8 +571,12 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
if (encode) { if (encode) {
const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([])) const buffer = await Filesystem.readBytes(full).catch(() => Buffer.from([]))
const content = buffer.toString("base64") return {
return { type: "text", content, mimeType, encoding: "base64" } type: "text",
content: buffer.toString("base64"),
mimeType,
encoding: "base64",
}
} }
const content = (await Filesystem.readText(full).catch(() => "")).trim() const content = (await Filesystem.readText(full).catch(() => "")).trim()
@ -603,7 +587,9 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
).text() ).text()
if (!diff.trim()) { if (!diff.trim()) {
diff = ( diff = (
await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], { cwd: instance.directory }) await git(["-c", "core.fsmonitor=false", "diff", "--staged", "--", file], {
cwd: instance.directory,
})
).text() ).text()
} }
if (diff.trim()) { if (diff.trim()) {
@ -612,64 +598,64 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
context: Infinity, context: Infinity,
ignoreWhitespace: true, ignoreWhitespace: true,
}) })
const diff = formatPatch(patch) return {
return { type: "text", content, patch, diff } type: "text",
content,
patch,
diff: formatPatch(patch),
}
} }
} }
return { type: "text", content } return { type: "text", content }
}) })
}) })
const list = Effect.fn("FileService.list")(function* (dir?: string) { const list = Effect.fn("File.list")(function* (dir?: string) {
return yield* Effect.promise(async () => { return yield* Effect.promise(async () => {
const exclude = [".git", ".DS_Store"] const exclude = [".git", ".DS_Store"]
let ignored = (_: string) => false let ignored = (_: string) => false
if (instance.project.vcs === "git") { if (instance.project.vcs === "git") {
const ig = ignore() const ig = ignore()
const gitignorePath = path.join(instance.project.worktree, ".gitignore") const gitignore = path.join(instance.project.worktree, ".gitignore")
if (await Filesystem.exists(gitignorePath)) { if (await Filesystem.exists(gitignore)) {
ig.add(await Filesystem.readText(gitignorePath)) ig.add(await Filesystem.readText(gitignore))
} }
const ignorePath = path.join(instance.project.worktree, ".ignore") const ignoreFile = path.join(instance.project.worktree, ".ignore")
if (await Filesystem.exists(ignorePath)) { if (await Filesystem.exists(ignoreFile)) {
ig.add(await Filesystem.readText(ignorePath)) ig.add(await Filesystem.readText(ignoreFile))
} }
ignored = ig.ignores.bind(ig) ignored = ig.ignores.bind(ig)
} }
const resolved = dir ? path.join(instance.directory, dir) : instance.directory
const resolved = dir ? path.join(instance.directory, dir) : instance.directory
if (!Instance.containsPath(resolved)) { if (!Instance.containsPath(resolved)) {
throw new Error(`Access denied: path escapes project directory`) throw new Error("Access denied: path escapes project directory")
} }
const nodes: File.Node[] = [] const nodes: File.Node[] = []
for (const entry of await fs.promises for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true }).catch(() => [])) {
.readdir(resolved, {
withFileTypes: true,
})
.catch(() => [])) {
if (exclude.includes(entry.name)) continue if (exclude.includes(entry.name)) continue
const fullPath = path.join(resolved, entry.name) const absolute = path.join(resolved, entry.name)
const relativePath = path.relative(instance.directory, fullPath) const file = path.relative(instance.directory, absolute)
const type = entry.isDirectory() ? "directory" : "file" const type = entry.isDirectory() ? "directory" : "file"
nodes.push({ nodes.push({
name: entry.name, name: entry.name,
path: relativePath, path: file,
absolute: fullPath, absolute,
type, type,
ignored: ignored(type === "directory" ? relativePath + "/" : relativePath), ignored: ignored(type === "directory" ? file + "/" : file),
}) })
} }
return nodes.sort((a, b) => { return nodes.sort((a, b) => {
if (a.type !== b.type) { if (a.type !== b.type) return a.type === "directory" ? -1 : 1
return a.type === "directory" ? -1 : 1
}
return a.name.localeCompare(b.name) return a.name.localeCompare(b.name)
}) })
}) })
}) })
const search = Effect.fn("FileService.search")(function* (input: { const search = Effect.fn("File.search")(function* (input: {
query: string query: string
limit?: number limit?: number
dirs?: boolean dirs?: boolean
@ -682,34 +668,19 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
log.info("search", { query, kind }) log.info("search", { query, kind })
const result = await getFiles() const result = await getFiles()
const hidden = (item: string) => {
const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
return normalized.split("/").some((p) => p.startsWith(".") && p.length > 1)
}
const preferHidden = query.startsWith(".") || query.includes("/.") const preferHidden = query.startsWith(".") || query.includes("/.")
const sortHiddenLast = (items: string[]) => {
if (preferHidden) return items
const visible: string[] = []
const hiddenItems: string[] = []
for (const item of items) {
const isHidden = hidden(item)
if (isHidden) hiddenItems.push(item)
if (!isHidden) visible.push(item)
}
return [...visible, ...hiddenItems]
}
if (!query) { if (!query) {
if (kind === "file") return result.files.slice(0, limit) if (kind === "file") return result.files.slice(0, limit)
return sortHiddenLast(result.dirs.toSorted()).slice(0, limit) return sortHiddenLast(result.dirs.toSorted(), preferHidden).slice(0, limit)
} }
const items = const items =
kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs] kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs]
const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((r) => r.target) const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
const output = kind === "directory" ? sortHiddenLast(sorted).slice(0, limit) : sorted const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
log.info("search", { query, kind, results: output.length }) log.info("search", { query, kind, results: output.length })
return output return output
@ -717,8 +688,7 @@ export class FileService extends ServiceMap.Service<FileService, FileService.Ser
}) })
log.info("init") log.info("init")
return Service.of({ init, status, read, list, search })
return FileService.of({ init, status, read, list, search })
}), }),
) )
} }

View file

@ -1,115 +1,110 @@
import { Log } from "../util/log" import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
import { Flag } from "@/flag/flag"
import { Filesystem } from "../util/filesystem"
import { Effect, Layer, ServiceMap, Semaphore } from "effect"
import { runPromiseInstance } from "@/effect/runtime" import { runPromiseInstance } from "@/effect/runtime"
import { Flag } from "@/flag/flag"
import type { SessionID } from "@/session/schema" import type { SessionID } from "@/session/schema"
import { Filesystem } from "../util/filesystem"
import { Log } from "../util/log"
const log = Log.create({ service: "file.time" }) export namespace FileTime {
const log = Log.create({ service: "file.time" })
export namespace FileTimeService { export type Stamp = {
export interface Service { 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, Map<string, Stamp>>, sessionID: SessionID) => {
const value = reads.get(sessionID)
if (value) return value
const next = new Map<string, Stamp>()
reads.set(sessionID, next)
return next
}
export interface Interface {
readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void> readonly read: (sessionID: SessionID, file: string) => Effect.Effect<void>
readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined> readonly get: (sessionID: SessionID, file: string) => Effect.Effect<Date | undefined>
readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void> readonly assert: (sessionID: SessionID, filepath: string) => Effect.Effect<void>
readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T> readonly withLock: <T>(filepath: string, fn: () => Promise<T>) => Effect.Effect<T>
} }
}
type Stamp = { export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/FileTime") {}
readonly read: Date
readonly mtime: number | undefined
readonly ctime: number | undefined
readonly size: number | undefined
}
function stamp(file: string): Stamp { export const layer = Layer.effect(
const stat = Filesystem.stat(file) Service,
const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
return {
read: new Date(),
mtime: stat?.mtime?.getTime(),
ctime: stat?.ctime?.getTime(),
size,
}
}
function session(reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) {
let value = reads.get(sessionID)
if (!value) {
value = new Map<string, Stamp>()
reads.set(sessionID, value)
}
return value
}
export class FileTimeService extends ServiceMap.Service<FileTimeService, FileTimeService.Service>()(
"@opencode/FileTime",
) {
static readonly layer = Layer.effect(
FileTimeService,
Effect.gen(function* () { Effect.gen(function* () {
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
const reads = new Map<SessionID, Map<string, Stamp>>() const reads = new Map<SessionID, Map<string, Stamp>>()
const locks = new Map<string, Semaphore.Semaphore>() const locks = new Map<string, Semaphore.Semaphore>()
function getLock(filepath: string) { const getLock = (filepath: string) => {
let lock = locks.get(filepath) const lock = locks.get(filepath)
if (!lock) { if (lock) return lock
lock = Semaphore.makeUnsafe(1)
locks.set(filepath, lock) const next = Semaphore.makeUnsafe(1)
} locks.set(filepath, next)
return lock return next
} }
return FileTimeService.of({ const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) {
read: Effect.fn("FileTimeService.read")(function* (sessionID: SessionID, file: string) { log.info("read", { sessionID, file })
log.info("read", { sessionID, file }) session(reads, sessionID).set(file, yield* stamp(file))
session(reads, sessionID).set(file, stamp(file))
}),
get: Effect.fn("FileTimeService.get")(function* (sessionID: SessionID, file: string) {
return reads.get(sessionID)?.get(file)?.read
}),
assert: Effect.fn("FileTimeService.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 = stamp(filepath)
const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size
if (changed) {
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.`,
)
}
}),
withLock: Effect.fn("FileTimeService.withLock")(function* <T>(filepath: string, fn: () => Promise<T>) {
const lock = getLock(filepath)
return yield* Effect.promise(fn).pipe(lock.withPermits(1))
}),
}) })
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* <T>(filepath: string, fn: () => Promise<T>) {
return yield* Effect.promise(fn).pipe(getLock(filepath).withPermits(1))
})
return Service.of({ read, get, assert, withLock })
}), }),
) )
}
export namespace FileTime {
export function read(sessionID: SessionID, file: string) { export function read(sessionID: SessionID, file: string) {
return runPromiseInstance(FileTimeService.use((s) => s.read(sessionID, file))) return runPromiseInstance(Service.use((s) => s.read(sessionID, file)))
} }
export function get(sessionID: SessionID, file: string) { export function get(sessionID: SessionID, file: string) {
return runPromiseInstance(FileTimeService.use((s) => s.get(sessionID, file))) return runPromiseInstance(Service.use((s) => s.get(sessionID, file)))
} }
export async function assert(sessionID: SessionID, filepath: string) { export async function assert(sessionID: SessionID, filepath: string) {
return runPromiseInstance(FileTimeService.use((s) => s.assert(sessionID, filepath))) return runPromiseInstance(Service.use((s) => s.assert(sessionID, filepath)))
} }
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> { export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
return runPromiseInstance(FileTimeService.use((s) => s.withLock(filepath, fn))) return runPromiseInstance(Service.use((s) => s.withLock(filepath, fn)))
} }
} }

View file

@ -1,89 +1,76 @@
import { BusEvent } from "@/bus/bus-event" import { Cause, Effect, Layer, ServiceMap } from "effect"
import { Bus } from "@/bus"
import { InstanceContext } from "@/effect/instance-context"
import { Instance } from "@/project/instance"
import z from "zod"
import { Log } from "../util/log"
import { FileIgnore } from "./ignore"
import { Config } from "../config/config"
import path from "path"
// @ts-ignore // @ts-ignore
import { createWrapper } from "@parcel/watcher/wrapper" import { createWrapper } from "@parcel/watcher/wrapper"
import { lazy } from "@/util/lazy"
import type ParcelWatcher from "@parcel/watcher" import type ParcelWatcher from "@parcel/watcher"
import { readdir } from "fs/promises" import { readdir } from "fs/promises"
import { git } from "@/util/git" import path from "path"
import { Protected } from "./protected" import z from "zod"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import { InstanceContext } from "@/effect/instance-context"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { Cause, Effect, Layer, ServiceMap } from "effect" import { Instance } from "@/project/instance"
import { git } from "@/util/git"
const SUBSCRIBE_TIMEOUT_MS = 10_000 import { lazy } from "@/util/lazy"
import { Config } from "../config/config"
import { FileIgnore } from "./ignore"
import { Protected } from "./protected"
import { Log } from "../util/log"
declare const OPENCODE_LIBC: string | undefined declare const OPENCODE_LIBC: string | undefined
const log = Log.create({ service: "file.watcher" })
const event = {
Updated: BusEvent.define(
"file.watcher.updated",
z.object({
file: z.string(),
event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]),
}),
),
}
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
try {
const binding = require(
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`,
)
return createWrapper(binding) as typeof import("@parcel/watcher")
} catch (error) {
log.error("failed to load watcher binding", { error })
return
}
})
function getBackend() {
if (process.platform === "win32") return "windows"
if (process.platform === "darwin") return "fs-events"
if (process.platform === "linux") return "inotify"
}
export namespace FileWatcher { export namespace FileWatcher {
export const Event = event const log = Log.create({ service: "file.watcher" })
/** Whether the native @parcel/watcher binding is available on this platform. */ const SUBSCRIBE_TIMEOUT_MS = 10_000
export const hasNativeBinding = () => !!watcher()
}
const init = Effect.fn("FileWatcherService.init")(function* () {}) export const Event = {
Updated: BusEvent.define(
export namespace FileWatcherService { "file.watcher.updated",
export interface Service { z.object({
readonly init: () => Effect.Effect<void> file: z.string(),
event: z.union([z.literal("add"), z.literal("change"), z.literal("unlink")]),
}),
),
} }
}
export class FileWatcherService extends ServiceMap.Service<FileWatcherService, FileWatcherService.Service>()( const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
"@opencode/FileWatcher", try {
) { const binding = require(
static readonly layer = Layer.effect( `@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`,
FileWatcherService, )
return createWrapper(binding) as typeof import("@parcel/watcher")
} catch (error) {
log.error("failed to load watcher binding", { error })
return
}
})
function getBackend() {
if (process.platform === "win32") return "windows"
if (process.platform === "darwin") return "fs-events"
if (process.platform === "linux") return "inotify"
}
export const hasNativeBinding = () => !!watcher()
export class Service extends ServiceMap.Service<Service, {}>()("@opencode/FileWatcher") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () { Effect.gen(function* () {
const instance = yield* InstanceContext const instance = yield* InstanceContext
if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return FileWatcherService.of({ init }) if (yield* Flag.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER) return Service.of({})
log.info("init", { directory: instance.directory }) log.info("init", { directory: instance.directory })
const backend = getBackend() const backend = getBackend()
if (!backend) { if (!backend) {
log.error("watcher backend not supported", { directory: instance.directory, platform: process.platform }) log.error("watcher backend not supported", { directory: instance.directory, platform: process.platform })
return FileWatcherService.of({ init }) return Service.of({})
} }
const w = watcher() const w = watcher()
if (!w) return FileWatcherService.of({ init }) if (!w) return Service.of({})
log.info("watcher backend", { directory: instance.directory, platform: process.platform, backend }) log.info("watcher backend", { directory: instance.directory, platform: process.platform, backend })
@ -93,9 +80,9 @@ export class FileWatcherService extends ServiceMap.Service<FileWatcherService, F
const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => { const cb: ParcelWatcher.SubscribeCallback = Instance.bind((err, evts) => {
if (err) return if (err) return
for (const evt of evts) { for (const evt of evts) {
if (evt.type === "create") Bus.publish(event.Updated, { file: evt.path, event: "add" }) if (evt.type === "create") Bus.publish(Event.Updated, { file: evt.path, event: "add" })
if (evt.type === "update") Bus.publish(event.Updated, { file: evt.path, event: "change" }) if (evt.type === "update") Bus.publish(Event.Updated, { file: evt.path, event: "change" })
if (evt.type === "delete") Bus.publish(event.Updated, { file: evt.path, event: "unlink" }) if (evt.type === "delete") Bus.publish(Event.Updated, { file: evt.path, event: "unlink" })
} }
}) })
@ -108,7 +95,6 @@ export class FileWatcherService extends ServiceMap.Service<FileWatcherService, F
Effect.timeout(SUBSCRIBE_TIMEOUT_MS), Effect.timeout(SUBSCRIBE_TIMEOUT_MS),
Effect.catchCause((cause) => { Effect.catchCause((cause) => {
log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) }) log.error("failed to subscribe", { dir, cause: Cause.pretty(cause) })
// Clean up a subscription that resolves after timeout
pending.then((s) => s.unsubscribe()).catch(() => {}) pending.then((s) => s.unsubscribe()).catch(() => {})
return Effect.void return Effect.void
}), }),
@ -137,11 +123,11 @@ export class FileWatcherService extends ServiceMap.Service<FileWatcherService, F
} }
} }
return FileWatcherService.of({ init }) return Service.of({})
}).pipe( }).pipe(
Effect.catchCause((cause) => { Effect.catchCause((cause) => {
log.error("failed to init watcher service", { cause: Cause.pretty(cause) }) log.error("failed to init watcher service", { cause: Cause.pretty(cause) })
return Effect.succeed(FileWatcherService.of({ init })) return Effect.succeed(Service.of({}))
}), }),
), ),
) )

View file

@ -1,21 +1,20 @@
import { Bus } from "../bus"
import { File } from "../file"
import { Log } from "../util/log"
import path from "path"
import z from "zod"
import * as Formatter from "./formatter"
import { Config } from "../config/config"
import { mergeDeep } from "remeda"
import { Instance } from "../project/instance"
import { Process } from "../util/process"
import { InstanceContext } from "@/effect/instance-context"
import { Effect, Layer, ServiceMap } from "effect" import { Effect, Layer, ServiceMap } from "effect"
import { runPromiseInstance } from "@/effect/runtime" import { runPromiseInstance } from "@/effect/runtime"
import { InstanceContext } from "@/effect/instance-context"
const log = Log.create({ service: "format" }) 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"
export namespace Format { export namespace Format {
const log = Log.create({ service: "format" })
export const Status = z export const Status = z
.object({ .object({
name: z.string(), name: z.string(),
@ -27,25 +26,14 @@ export namespace Format {
}) })
export type Status = z.infer<typeof Status> export type Status = z.infer<typeof Status>
export async function init() { export interface Interface {
return runPromiseInstance(FormatService.use((s) => s.init())) readonly status: () => Effect.Effect<Status[]>
} }
export async function status() { export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Format") {}
return runPromiseInstance(FormatService.use((s) => s.status()))
}
}
export namespace FormatService { export const layer = Layer.effect(
export interface Service { Service,
readonly init: () => Effect.Effect<void>
readonly status: () => Effect.Effect<Format.Status[]>
}
}
export class FormatService extends ServiceMap.Service<FormatService, FormatService.Service>()("@opencode/Format") {
static readonly layer = Layer.effect(
FormatService,
Effect.gen(function* () { Effect.gen(function* () {
const instance = yield* InstanceContext const instance = yield* InstanceContext
@ -122,11 +110,12 @@ export class FormatService extends ServiceMap.Service<FormatService, FormatServi
}, },
) )
const exit = await proc.exited const exit = await proc.exited
if (exit !== 0) if (exit !== 0) {
log.error("failed", { log.error("failed", {
command: item.command, command: item.command,
...item.environment, ...item.environment,
}) })
}
} catch (error) { } catch (error) {
log.error("failed to format file", { log.error("failed to format file", {
error, error,
@ -142,10 +131,8 @@ export class FormatService extends ServiceMap.Service<FormatService, FormatServi
yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)) yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
log.info("init") log.info("init")
const init = Effect.fn("FormatService.init")(function* () {}) const status = Effect.fn("Format.status")(function* () {
const result: Status[] = []
const status = Effect.fn("FormatService.status")(function* () {
const result: Format.Status[] = []
for (const formatter of Object.values(formatters)) { for (const formatter of Object.values(formatters)) {
const isOn = yield* Effect.promise(() => isEnabled(formatter)) const isOn = yield* Effect.promise(() => isEnabled(formatter))
result.push({ result.push({
@ -157,7 +144,11 @@ export class FormatService extends ServiceMap.Service<FormatService, FormatServi
return result return result
}) })
return FormatService.of({ init, status }) return Service.of({ status })
}), }),
) )
export async function status() {
return runPromiseInstance(Service.use((s) => s.status()))
}
} }

View file

@ -1,30 +1,23 @@
import { Plugin } from "../plugin" import { Plugin } from "../plugin"
import { Format } from "../format"
import { LSP } from "../lsp" import { LSP } from "../lsp"
import { FileWatcherService } from "../file/watcher"
import { File } from "../file" import { File } from "../file"
import { Project } from "./project" import { Project } from "./project"
import { Bus } from "../bus" import { Bus } from "../bus"
import { Command } from "../command" import { Command } from "../command"
import { Instance } from "./instance" import { Instance } from "./instance"
import { VcsService } from "./vcs"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { ShareNext } from "@/share/share-next" import { ShareNext } from "@/share/share-next"
import { runPromiseInstance } from "@/effect/runtime"
export async function InstanceBootstrap() { export async function InstanceBootstrap() {
Log.Default.info("bootstrapping", { directory: Instance.directory }) Log.Default.info("bootstrapping", { directory: Instance.directory })
await Plugin.init() await Plugin.init()
ShareNext.init() ShareNext.init()
await Format.init()
await LSP.init() await LSP.init()
await runPromiseInstance(FileWatcherService.use((service) => service.init()))
File.init() File.init()
await runPromiseInstance(VcsService.use((s) => s.init()))
Bus.subscribe(Command.Event.Executed, async (payload) => { Bus.subscribe(Command.Event.Executed, async (payload) => {
if (payload.properties.name === Command.Default.INIT) { if (payload.properties.name === Command.Default.INIT) {
await Project.setInitialized(Instance.project.id) Project.setInitialized(Instance.project.id)
} }
}) })
} }

View file

@ -1,16 +1,16 @@
import { BusEvent } from "@/bus/bus-event" import { Effect, Layer, ServiceMap } from "effect"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import z from "zod" import { BusEvent } from "@/bus/bus-event"
import { Log } from "@/util/log"
import { Instance } from "./instance"
import { InstanceContext } from "@/effect/instance-context" import { InstanceContext } from "@/effect/instance-context"
import { FileWatcher } from "@/file/watcher" import { FileWatcher } from "@/file/watcher"
import { Log } from "@/util/log"
import { git } from "@/util/git" import { git } from "@/util/git"
import { Effect, Layer, ServiceMap } from "effect" import { Instance } from "./instance"
import z from "zod"
const log = Log.create({ service: "vcs" })
export namespace Vcs { export namespace Vcs {
const log = Log.create({ service: "vcs" })
export const Event = { export const Event = {
BranchUpdated: BusEvent.define( BranchUpdated: BusEvent.define(
"vcs.branch.updated", "vcs.branch.updated",
@ -28,18 +28,15 @@ export namespace Vcs {
ref: "VcsInfo", ref: "VcsInfo",
}) })
export type Info = z.infer<typeof Info> export type Info = z.infer<typeof Info>
}
export namespace VcsService { export interface Interface {
export interface Service {
readonly init: () => Effect.Effect<void>
readonly branch: () => Effect.Effect<string | undefined> readonly branch: () => Effect.Effect<string | undefined>
} }
}
export class VcsService extends ServiceMap.Service<VcsService, VcsService.Service>()("@opencode/Vcs") { export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Vcs") {}
static readonly layer = Layer.effect(
VcsService, export const layer = Layer.effect(
Service,
Effect.gen(function* () { Effect.gen(function* () {
const instance = yield* InstanceContext const instance = yield* InstanceContext
let current: string | undefined let current: string | undefined
@ -65,7 +62,7 @@ export class VcsService extends ServiceMap.Service<VcsService, VcsService.Servic
if (next !== current) { if (next !== current) {
log.info("branch changed", { from: current, to: next }) log.info("branch changed", { from: current, to: next })
current = next current = next
Bus.publish(Vcs.Event.BranchUpdated, { branch: next }) Bus.publish(Event.BranchUpdated, { branch: next })
} }
}), }),
) )
@ -73,9 +70,8 @@ export class VcsService extends ServiceMap.Service<VcsService, VcsService.Servic
yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)) yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
} }
return VcsService.of({ return Service.of({
init: Effect.fn("VcsService.init")(function* () {}), branch: Effect.fn("Vcs.branch")(function* () {
branch: Effect.fn("VcsService.branch")(function* () {
return current return current
}), }),
}) })

View file

@ -1,71 +1,65 @@
import type { AuthOuathResult } from "@opencode-ai/plugin" import type { AuthOuathResult } from "@opencode-ai/plugin"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
import * as Auth from "@/auth/effect"
import { ProviderID } from "./schema"
import { Effect, Layer, Record, ServiceMap, Struct } from "effect" import { Effect, Layer, Record, ServiceMap, Struct } from "effect"
import { filter, fromEntries, map, pipe } from "remeda" import { filter, fromEntries, map, pipe } from "remeda"
import z from "zod" import z from "zod"
import * as Auth from "@/auth/effect"
import { ProviderID } from "./schema"
export const Method = z export namespace ProviderAuthEffect {
.object({ export const Method = z
type: z.union([z.literal("oauth"), z.literal("api")]), .object({
label: z.string(), type: z.union([z.literal("oauth"), z.literal("api")]),
}) label: z.string(),
.meta({ })
ref: "ProviderAuthMethod", .meta({
}) ref: "ProviderAuthMethod",
export type Method = z.infer<typeof Method> })
export type Method = z.infer<typeof Method>
export const Authorization = z export const Authorization = z
.object({ .object({
url: z.string(), url: z.string(),
method: z.union([z.literal("auto"), z.literal("code")]), method: z.union([z.literal("auto"), z.literal("code")]),
instructions: z.string(), instructions: z.string(),
}) })
.meta({ .meta({
ref: "ProviderAuthAuthorization", ref: "ProviderAuthAuthorization",
}) })
export type Authorization = z.infer<typeof Authorization> export type Authorization = z.infer<typeof Authorization>
export const OauthMissing = NamedError.create( export const OauthMissing = NamedError.create(
"ProviderAuthOauthMissing", "ProviderAuthOauthMissing",
z.object({ z.object({
providerID: ProviderID.zod, providerID: ProviderID.zod,
}), }),
) )
export const OauthCodeMissing = NamedError.create( export const OauthCodeMissing = NamedError.create(
"ProviderAuthOauthCodeMissing", "ProviderAuthOauthCodeMissing",
z.object({ z.object({
providerID: ProviderID.zod, providerID: ProviderID.zod,
}), }),
) )
export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({})) export const OauthCallbackFailed = NamedError.create("ProviderAuthOauthCallbackFailed", z.object({}))
export type ProviderAuthError = export type Error =
| Auth.AuthEffect.AuthServiceError | Auth.AuthEffect.AuthServiceError
| InstanceType<typeof OauthMissing> | InstanceType<typeof OauthMissing>
| InstanceType<typeof OauthCodeMissing> | InstanceType<typeof OauthCodeMissing>
| InstanceType<typeof OauthCallbackFailed> | InstanceType<typeof OauthCallbackFailed>
export namespace ProviderAuthService { export interface Interface {
export interface Service {
readonly methods: () => Effect.Effect<Record<string, Method[]>> readonly methods: () => Effect.Effect<Record<string, Method[]>>
readonly authorize: (input: { providerID: ProviderID; method: number }) => Effect.Effect<Authorization | undefined> readonly authorize: (input: { providerID: ProviderID; method: number }) => Effect.Effect<Authorization | undefined>
readonly callback: (input: { readonly callback: (input: { providerID: ProviderID; method: number; code?: string }) => Effect.Effect<void, Error>
providerID: ProviderID
method: number
code?: string
}) => Effect.Effect<void, ProviderAuthError>
} }
}
export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService, ProviderAuthService.Service>()( export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {}
"@opencode/ProviderAuth",
) { export const layer = Layer.effect(
static readonly layer = Layer.effect( Service,
ProviderAuthService,
Effect.gen(function* () { Effect.gen(function* () {
const auth = yield* Auth.AuthEffect.Service const auth = yield* Auth.AuthEffect.Service
const hooks = yield* Effect.promise(async () => { const hooks = yield* Effect.promise(async () => {
@ -79,11 +73,11 @@ export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService,
}) })
const pending = new Map<ProviderID, AuthOuathResult>() const pending = new Map<ProviderID, AuthOuathResult>()
const methods = Effect.fn("ProviderAuthService.methods")(function* () { const methods = Effect.fn("ProviderAuth.methods")(function* () {
return Record.map(hooks, (item) => item.methods.map((method): Method => Struct.pick(method, ["type", "label"]))) return Record.map(hooks, (item) => item.methods.map((method): Method => Struct.pick(method, ["type", "label"])))
}) })
const authorize = Effect.fn("ProviderAuthService.authorize")(function* (input: { const authorize = Effect.fn("ProviderAuth.authorize")(function* (input: {
providerID: ProviderID providerID: ProviderID
method: number method: number
}) { }) {
@ -98,15 +92,16 @@ export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService,
} }
}) })
const callback = Effect.fn("ProviderAuthService.callback")(function* (input: { const callback = Effect.fn("ProviderAuth.callback")(function* (input: {
providerID: ProviderID providerID: ProviderID
method: number method: number
code?: string code?: string
}) { }) {
const match = pending.get(input.providerID) const match = pending.get(input.providerID)
if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID })) if (!match) return yield* Effect.fail(new OauthMissing({ providerID: input.providerID }))
if (match.method === "code" && !input.code) if (match.method === "code" && !input.code) {
return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID })) return yield* Effect.fail(new OauthCodeMissing({ providerID: input.providerID }))
}
const result = yield* Effect.promise(() => const result = yield* Effect.promise(() =>
match.method === "code" ? match.callback(input.code!) : match.callback(), match.method === "code" ? match.callback(input.code!) : match.callback(),
@ -131,13 +126,9 @@ export class ProviderAuthService extends ServiceMap.Service<ProviderAuthService,
} }
}) })
return ProviderAuthService.of({ return Service.of({ methods, authorize, callback })
methods,
authorize,
callback,
})
}), }),
) )
static readonly defaultLayer = ProviderAuthService.layer.pipe(Layer.provide(Auth.AuthEffect.defaultLayer)) export const defaultLayer = layer.pipe(Layer.provide(Auth.AuthEffect.defaultLayer))
} }

View file

@ -2,7 +2,7 @@ import z from "zod"
import { runPromiseInstance } from "@/effect/runtime" import { runPromiseInstance } from "@/effect/runtime"
import { fn } from "@/util/fn" import { fn } from "@/util/fn"
import * as S from "./auth-service" import { ProviderAuthEffect as S } from "./auth-effect"
import { ProviderID } from "./schema" import { ProviderID } from "./schema"
export namespace ProviderAuth { export namespace ProviderAuth {
@ -10,7 +10,7 @@ export namespace ProviderAuth {
export type Method = S.Method export type Method = S.Method
export async function methods() { export async function methods() {
return runPromiseInstance(S.ProviderAuthService.use((service) => service.methods())) return runPromiseInstance(S.Service.use((service) => service.methods()))
} }
export const Authorization = S.Authorization export const Authorization = S.Authorization
@ -22,7 +22,7 @@ export namespace ProviderAuth {
method: z.number(), method: z.number(),
}), }),
async (input): Promise<Authorization | undefined> => async (input): Promise<Authorization | undefined> =>
runPromiseInstance(S.ProviderAuthService.use((service) => service.authorize(input))), runPromiseInstance(S.Service.use((service) => service.authorize(input))),
) )
export const callback = fn( export const callback = fn(
@ -31,7 +31,7 @@ export namespace ProviderAuth {
method: z.number(), method: z.number(),
code: z.string().optional(), code: z.string().optional(),
}), }),
async (input) => runPromiseInstance(S.ProviderAuthService.use((service) => service.callback(input))), async (input) => runPromiseInstance(S.Service.use((service) => service.callback(input))),
) )
export import OauthMissing = S.OauthMissing export import OauthMissing = S.OauthMissing

View file

@ -14,7 +14,7 @@ import { LSP } from "../lsp"
import { Format } from "../format" import { Format } from "../format"
import { TuiRoutes } from "./routes/tui" import { TuiRoutes } from "./routes/tui"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Vcs, VcsService } from "../project/vcs" import { Vcs } from "../project/vcs"
import { runPromiseInstance } from "@/effect/runtime" import { runPromiseInstance } from "@/effect/runtime"
import { Agent } from "../agent/agent" import { Agent } from "../agent/agent"
import { Skill } from "../skill/skill" import { Skill } from "../skill/skill"
@ -331,7 +331,7 @@ export namespace Server {
}, },
}), }),
async (c) => { async (c) => {
const branch = await runPromiseInstance(VcsService.use((s) => s.branch())) const branch = await runPromiseInstance(Vcs.Service.use((s) => s.branch()))
return c.json({ return c.json({
branch, branch,
}) })

View file

@ -1,116 +1,117 @@
import { NodeFileSystem, NodePath } from "@effect/platform-node" import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Effect, FileSystem, Layer, Path, Schema, ServiceMap } from "effect" import { Effect, FileSystem, Layer, Path, Schema, ServiceMap } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { Global } from "../global" import { Global } from "../global"
import { Log } from "../util/log" import { Log } from "../util/log"
import { withTransientReadRetry } from "@/util/effect-http-client"
class IndexSkill extends Schema.Class<IndexSkill>("IndexSkill")({ export namespace Discovery {
name: Schema.String, const skillConcurrency = 4
files: Schema.Array(Schema.String), const fileConcurrency = 8
}) {}
class Index extends Schema.Class<Index>("Index")({ class IndexSkill extends Schema.Class<IndexSkill>("IndexSkill")({
skills: Schema.Array(IndexSkill), name: Schema.String,
}) {} files: Schema.Array(Schema.String),
}) {}
const skillConcurrency = 4 class Index extends Schema.Class<Index>("Index")({
const fileConcurrency = 8 skills: Schema.Array(IndexSkill),
}) {}
export namespace DiscoveryService { export interface Interface {
export interface Service {
readonly pull: (url: string) => Effect.Effect<string[]> readonly pull: (url: string) => Effect.Effect<string[]>
} }
}
export class DiscoveryService extends ServiceMap.Service<DiscoveryService, DiscoveryService.Service>()( export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SkillDiscovery") {}
"@opencode/SkillDiscovery",
) {
static readonly layer = Layer.effect(
DiscoveryService,
Effect.gen(function* () {
const log = Log.create({ service: "skill-discovery" })
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
const cache = path.join(Global.Path.cache, "skills")
const download = Effect.fn("DiscoveryService.download")(function* (url: string, dest: string) { export const layer: Layer.Layer<Service, never, FileSystem.FileSystem | Path.Path | HttpClient.HttpClient> =
if (yield* fs.exists(dest).pipe(Effect.orDie)) return true Layer.effect(
Service,
Effect.gen(function* () {
const log = Log.create({ service: "skill-discovery" })
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
const cache = path.join(Global.Path.cache, "skills")
return yield* HttpClientRequest.get(url).pipe( const download = Effect.fn("Discovery.download")(function* (url: string, dest: string) {
http.execute, if (yield* fs.exists(dest).pipe(Effect.orDie)) return true
Effect.flatMap((res) => res.arrayBuffer),
Effect.flatMap((body) =>
fs
.makeDirectory(path.dirname(dest), { recursive: true })
.pipe(Effect.flatMap(() => fs.writeFile(dest, new Uint8Array(body)))),
),
Effect.as(true),
Effect.catch((err) =>
Effect.sync(() => {
log.error("failed to download", { url, err })
return false
}),
),
)
})
const pull: DiscoveryService.Service["pull"] = Effect.fn("DiscoveryService.pull")(function* (url: string) { return yield* HttpClientRequest.get(url).pipe(
const base = url.endsWith("/") ? url : `${url}/` http.execute,
const index = new URL("index.json", base).href Effect.flatMap((res) => res.arrayBuffer),
const host = base.slice(0, -1) Effect.flatMap((body) =>
fs
log.info("fetching index", { url: index }) .makeDirectory(path.dirname(dest), { recursive: true })
.pipe(Effect.flatMap(() => fs.writeFile(dest, new Uint8Array(body)))),
const data = yield* HttpClientRequest.get(index).pipe( ),
HttpClientRequest.acceptJson, Effect.as(true),
http.execute, Effect.catch((err) =>
Effect.flatMap(HttpClientResponse.schemaBodyJson(Index)), Effect.sync(() => {
Effect.catch((err) => log.error("failed to download", { url, err })
Effect.sync(() => { return false
log.error("failed to fetch index", { url: index, err }) }),
return null ),
}), )
),
)
if (!data) return []
const list = data.skills.filter((skill) => {
if (!skill.files.includes("SKILL.md")) {
log.warn("skill entry missing SKILL.md", { url: index, skill: skill.name })
return false
}
return true
}) })
const dirs = yield* Effect.forEach( const pull = Effect.fn("Discovery.pull")(function* (url: string) {
list, const base = url.endsWith("/") ? url : `${url}/`
(skill) => const index = new URL("index.json", base).href
Effect.gen(function* () { const host = base.slice(0, -1)
const root = path.join(cache, skill.name)
yield* Effect.forEach( log.info("fetching index", { url: index })
skill.files,
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)),
{ concurrency: fileConcurrency },
)
const md = path.join(root, "SKILL.md") const data = yield* HttpClientRequest.get(index).pipe(
return (yield* fs.exists(md).pipe(Effect.orDie)) ? root : null HttpClientRequest.acceptJson,
}), http.execute,
{ concurrency: skillConcurrency }, Effect.flatMap(HttpClientResponse.schemaBodyJson(Index)),
) Effect.catch((err) =>
Effect.sync(() => {
log.error("failed to fetch index", { url: index, err })
return null
}),
),
)
return dirs.filter((dir): dir is string => dir !== null) if (!data) return []
})
return DiscoveryService.of({ pull }) const list = data.skills.filter((skill) => {
}), if (!skill.files.includes("SKILL.md")) {
) log.warn("skill entry missing SKILL.md", { url: index, skill: skill.name })
return false
}
return true
})
static readonly defaultLayer = DiscoveryService.layer.pipe( const dirs = yield* Effect.forEach(
list,
(skill) =>
Effect.gen(function* () {
const root = path.join(cache, skill.name)
yield* Effect.forEach(
skill.files,
(file) => download(new URL(file, `${host}/${skill.name}/`).href, path.join(root, file)),
{
concurrency: fileConcurrency,
},
)
const md = path.join(root, "SKILL.md")
return (yield* fs.exists(md).pipe(Effect.orDie)) ? root : null
}),
{ concurrency: skillConcurrency },
)
return dirs.filter((dir): dir is string => dir !== null)
})
return Service.of({ pull })
}),
)
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
Layer.provide(FetchHttpClient.layer), Layer.provide(FetchHttpClient.layer),
Layer.provide(NodeFileSystem.layer), Layer.provide(NodeFileSystem.layer),
Layer.provide(NodePath.layer), Layer.provide(NodePath.layer),

View file

@ -1,34 +1,30 @@
import z from "zod"
import path from "path"
import os from "os" import os from "os"
import { Config } from "../config/config" import path from "path"
import { Instance } from "../project/instance"
import { NamedError } from "@opencode-ai/util/error"
import { ConfigMarkdown } from "../config/markdown"
import { Log } from "../util/log"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { Flag } from "@/flag/flag"
import { Bus } from "@/bus"
import { DiscoveryService } from "./discovery"
import { Glob } from "../util/glob"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import type { Agent } from "@/agent/agent" import z from "zod"
import { PermissionNext } from "@/permission"
import { InstanceContext } from "@/effect/instance-context"
import { Effect, Layer, ServiceMap } from "effect" 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 { runPromiseInstance } from "@/effect/runtime"
import { Flag } from "@/flag/flag"
const log = Log.create({ service: "skill" }) import { Global } from "@/global"
import { PermissionNext } from "@/permission"
// External skill directories to search for (project-level and global) import { Filesystem } from "@/util/filesystem"
// These follow the directory layout used by Claude Code and other agents. import { Config } from "../config/config"
const EXTERNAL_DIRS = [".claude", ".agents"] import { ConfigMarkdown } from "../config/markdown"
const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md" import { Glob } from "../util/glob"
const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md" import { Log } from "../util/log"
const SKILL_PATTERN = "**/SKILL.md" import { Discovery } from "./discovery"
export namespace Skill { 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({ export const Info = z.object({
name: z.string(), name: z.string(),
description: z.string(), description: z.string(),
@ -55,213 +51,205 @@ export namespace Skill {
}), }),
) )
type State = {
skills: Record<string, Info>
dirs: Set<string>
task?: Promise<void>
}
type Cache = State & {
ensure: () => Promise<void>
}
export interface Interface {
readonly get: (name: string) => Effect.Effect<Info | undefined>
readonly all: () => Effect.Effect<Info[]>
readonly dirs: () => Effect.Effect<string[]>
readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]>
}
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<string>(),
}
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<Service, Interface>()("@opencode/Skill") {}
export const layer: Layer.Layer<Service, never, InstanceContext | Discovery.Service> = 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)
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<Service, never, InstanceContext> = layer.pipe(
Layer.provide(Discovery.defaultLayer),
)
export async function get(name: string) { export async function get(name: string) {
return runPromiseInstance(SkillService.use((s) => s.get(name))) return runPromiseInstance(Service.use((skill) => skill.get(name)))
} }
export async function all() { export async function all() {
return runPromiseInstance(SkillService.use((s) => s.all())) return runPromiseInstance(Service.use((skill) => skill.all()))
} }
export async function dirs() { export async function dirs() {
return runPromiseInstance(SkillService.use((s) => s.dirs())) return runPromiseInstance(Service.use((skill) => skill.dirs()))
} }
export async function available(agent?: Agent.Info) { export async function available(agent?: Agent.Info) {
return runPromiseInstance(SkillService.use((s) => s.available(agent))) return runPromiseInstance(Service.use((skill) => skill.available(agent)))
} }
export function fmt(list: Info[], opts: { verbose: boolean }) { export function fmt(list: Info[], opts: { verbose: boolean }) {
if (list.length === 0) { if (list.length === 0) return "No skills are currently available."
return "No skills are currently available."
}
if (opts.verbose) { if (opts.verbose) {
return [ return [
"<available_skills>", "<available_skills>",
...list.flatMap((skill) => [ ...list.flatMap((skill) => [
` <skill>`, " <skill>",
` <name>${skill.name}</name>`, ` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`, ` <description>${skill.description}</description>`,
` <location>${pathToFileURL(skill.location).href}</location>`, ` <location>${pathToFileURL(skill.location).href}</location>`,
` </skill>`, " </skill>",
]), ]),
"</available_skills>", "</available_skills>",
].join("\n") ].join("\n")
} }
return ["## Available Skills", ...list.flatMap((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
} }
} }
export namespace SkillService {
export interface Service {
readonly get: (name: string) => Effect.Effect<Skill.Info | undefined>
readonly all: () => Effect.Effect<Skill.Info[]>
readonly dirs: () => Effect.Effect<string[]>
readonly available: (agent?: Agent.Info) => Effect.Effect<Skill.Info[]>
}
}
export class SkillService extends ServiceMap.Service<SkillService, SkillService.Service>()("@opencode/Skill") {
static readonly layer = Layer.effect(
SkillService,
Effect.gen(function* () {
const instance = yield* InstanceContext
const discovery = yield* DiscoveryService
const skills: Record<string, Skill.Info> = {}
const skillDirs = new Set<string>()
let task: Promise<void> | undefined
const addSkill = async (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 = Skill.Info.pick({ name: true, description: true }).safeParse(md.data)
if (!parsed.success) return
// Warn on duplicate skill names
if (skills[parsed.data.name]) {
log.warn("duplicate skill name", {
name: parsed.data.name,
existing: skills[parsed.data.name].location,
duplicate: match,
})
}
skillDirs.add(path.dirname(match))
skills[parsed.data.name] = {
name: parsed.data.name,
description: parsed.data.description,
location: match,
content: md.content,
}
}
const scanExternal = async (root: string, scope: "global" | "project") => {
return Glob.scan(EXTERNAL_SKILL_PATTERN, {
cwd: root,
absolute: true,
include: "file",
dot: true,
symlink: true,
})
.then((matches) => Promise.all(matches.map(addSkill)))
.catch((error) => {
log.error(`failed to scan ${scope} skills`, { dir: root, error })
})
}
function ensureScanned() {
if (task) return task
task = (async () => {
// Scan external skill directories (.claude/skills/, .agents/skills/, etc.)
// Load global (home) first, then project-level (so project-level overwrites)
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 scanExternal(root, "global")
}
for await (const root of Filesystem.up({
targets: EXTERNAL_DIRS,
start: instance.directory,
stop: instance.project.worktree,
})) {
await scanExternal(root, "project")
}
}
// Scan .opencode/skill/ directories
for (const dir of await Config.directories()) {
const matches = await Glob.scan(OPENCODE_SKILL_PATTERN, {
cwd: dir,
absolute: true,
include: "file",
symlink: true,
})
for (const match of matches) {
await addSkill(match)
}
}
// Scan additional skill paths from config
const config = await Config.get()
for (const skillPath of config.skills?.paths ?? []) {
const expanded = skillPath.startsWith("~/") ? path.join(os.homedir(), skillPath.slice(2)) : skillPath
const resolved = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded)
if (!(await Filesystem.isDir(resolved))) {
log.warn("skill path not found", { path: resolved })
continue
}
const matches = await Glob.scan(SKILL_PATTERN, {
cwd: resolved,
absolute: true,
include: "file",
symlink: true,
})
for (const match of matches) {
await addSkill(match)
}
}
// Download and load skills from URLs
for (const url of config.skills?.urls ?? []) {
const list = await Effect.runPromise(discovery.pull(url))
for (const dir of list) {
skillDirs.add(dir)
const matches = await Glob.scan(SKILL_PATTERN, {
cwd: dir,
absolute: true,
include: "file",
symlink: true,
})
for (const match of matches) {
await addSkill(match)
}
}
}
log.info("init", { count: Object.keys(skills).length })
})().catch((err) => {
task = undefined
throw err
})
return task
}
return SkillService.of({
get: Effect.fn("SkillService.get")(function* (name: string) {
yield* Effect.promise(() => ensureScanned())
return skills[name]
}),
all: Effect.fn("SkillService.all")(function* () {
yield* Effect.promise(() => ensureScanned())
return Object.values(skills)
}),
dirs: Effect.fn("SkillService.dirs")(function* () {
yield* Effect.promise(() => ensureScanned())
return Array.from(skillDirs)
}),
available: Effect.fn("SkillService.available")(function* (agent?: Agent.Info) {
yield* Effect.promise(() => ensureScanned())
const list = Object.values(skills)
if (!agent) return list
return list.filter(
(skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny",
)
}),
})
}),
).pipe(Layer.provide(DiscoveryService.defaultLayer))
}

View file

@ -9,20 +9,6 @@ import { Config } from "../config/config"
import { Global } from "../global" import { Global } from "../global"
import { Log } from "../util/log" import { Log } from "../util/log"
const log = Log.create({ service: "snapshot" })
const PRUNE = "7.days"
// Common git config flags shared across snapshot operations
const GIT_CORE = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"]
const GIT_CFG = ["-c", "core.autocrlf=false", ...GIT_CORE]
const GIT_CFG_QUOTE = [...GIT_CFG, "-c", "core.quotepath=false"]
interface GitResult {
readonly code: ChildProcessSpawner.ExitCode
readonly text: string
readonly stderr: string
}
export namespace Snapshot { export namespace Snapshot {
export const Patch = z.object({ export const Patch = z.object({
hash: z.string(), hash: z.string(),
@ -45,36 +31,46 @@ export namespace Snapshot {
export type FileDiff = z.infer<typeof FileDiff> export type FileDiff = z.infer<typeof FileDiff>
export async function cleanup() { export async function cleanup() {
return runPromiseInstance(SnapshotService.use((s) => s.cleanup())) return runPromiseInstance(Service.use((svc) => svc.cleanup()))
} }
export async function track() { export async function track() {
return runPromiseInstance(SnapshotService.use((s) => s.track())) return runPromiseInstance(Service.use((svc) => svc.track()))
} }
export async function patch(hash: string) { export async function patch(hash: string) {
return runPromiseInstance(SnapshotService.use((s) => s.patch(hash))) return runPromiseInstance(Service.use((svc) => svc.patch(hash)))
} }
export async function restore(snapshot: string) { export async function restore(snapshot: string) {
return runPromiseInstance(SnapshotService.use((s) => s.restore(snapshot))) return runPromiseInstance(Service.use((svc) => svc.restore(snapshot)))
} }
export async function revert(patches: Patch[]) { export async function revert(patches: Patch[]) {
return runPromiseInstance(SnapshotService.use((s) => s.revert(patches))) return runPromiseInstance(Service.use((svc) => svc.revert(patches)))
} }
export async function diff(hash: string) { export async function diff(hash: string) {
return runPromiseInstance(SnapshotService.use((s) => s.diff(hash))) return runPromiseInstance(Service.use((svc) => svc.diff(hash)))
} }
export async function diffFull(from: string, to: string) { export async function diffFull(from: string, to: string) {
return runPromiseInstance(SnapshotService.use((s) => s.diffFull(from, to))) return runPromiseInstance(Service.use((svc) => svc.diffFull(from, to)))
} }
}
export namespace SnapshotService { const log = Log.create({ service: "snapshot" })
export interface Service { 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<void> readonly cleanup: () => Effect.Effect<void>
readonly track: () => Effect.Effect<string | undefined> readonly track: () => Effect.Effect<string | undefined>
readonly patch: (hash: string) => Effect.Effect<Snapshot.Patch> readonly patch: (hash: string) => Effect.Effect<Snapshot.Patch>
@ -83,38 +79,40 @@ export namespace SnapshotService {
readonly diff: (hash: string) => Effect.Effect<string> readonly diff: (hash: string) => Effect.Effect<string>
readonly diffFull: (from: string, to: string) => Effect.Effect<Snapshot.FileDiff[]> readonly diffFull: (from: string, to: string) => Effect.Effect<Snapshot.FileDiff[]>
} }
}
export class SnapshotService extends ServiceMap.Service<SnapshotService, SnapshotService.Service>()( export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Snapshot") {}
"@opencode/Snapshot",
) { export const layer: Layer.Layer<
static readonly layer = Layer.effect( Service,
SnapshotService, never,
InstanceContext | FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner
> = Layer.effect(
Service,
Effect.gen(function* () { Effect.gen(function* () {
const ctx = yield* InstanceContext const ctx = yield* InstanceContext
const fileSystem = yield* FileSystem.FileSystem const fs = yield* FileSystem.FileSystem
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const { directory, worktree, project } = ctx const directory = ctx.directory
const isGit = project.vcs === "git" const worktree = ctx.worktree
const snapshotGit = path.join(Global.Path.data, "snapshot", project.id) const project = ctx.project
const gitdir = path.join(Global.Path.data, "snapshot", project.id)
const gitArgs = (cmd: string[]) => ["--git-dir", snapshotGit, "--work-tree", worktree, ...cmd] const args = (cmd: string[]) => ["--git-dir", gitdir, "--work-tree", worktree, ...cmd]
// Run git with nothrow semantics — always returns a result, never fails
const git = Effect.fnUntraced( const git = Effect.fnUntraced(
function* (args: string[], opts?: { cwd?: string; env?: Record<string, string> }) { function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
const command = ChildProcess.make("git", args, { const proc = ChildProcess.make("git", cmd, {
cwd: opts?.cwd, cwd: opts?.cwd,
env: opts?.env, env: opts?.env,
extendEnv: true, extendEnv: true,
}) })
const handle = yield* spawner.spawn(command) const handle = yield* spawner.spawn(proc)
const [text, stderr] = yield* Effect.all( const [text, stderr] = yield* Effect.all(
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))], [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
{ concurrency: 2 }, { concurrency: 2 },
) )
const code = yield* handle.exitCode const code = yield* handle.exitCode
return { code, text, stderr } return { code, text, stderr } satisfies GitResult
}, },
Effect.scoped, Effect.scoped,
Effect.catch((err) => Effect.catch((err) =>
@ -126,56 +124,47 @@ export class SnapshotService extends ServiceMap.Service<SnapshotService, Snapsho
), ),
) )
// FileSystem helpers — orDie converts PlatformError to defects const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
const exists = (p: string) => fileSystem.exists(p).pipe(Effect.orDie) const mkdir = (dir: string) => fs.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie)
const mkdir = (p: string) => fileSystem.makeDirectory(p, { recursive: true }).pipe(Effect.orDie) const write = (file: string, text: string) => fs.writeFileString(file, text).pipe(Effect.orDie)
const writeFile = (p: string, content: string) => fileSystem.writeFileString(p, content).pipe(Effect.orDie) const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
const readFile = (p: string) => fileSystem.readFileString(p).pipe(Effect.catch(() => Effect.succeed(""))) const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
const removeFile = (p: string) => fileSystem.remove(p).pipe(Effect.catch(() => Effect.void))
// --- internal Effect helpers --- const enabled = Effect.fnUntraced(function* () {
if (project.vcs !== "git") return false
const isEnabled = Effect.gen(function* () { return (yield* Effect.promise(() => Config.get())).snapshot !== false
if (!isGit) return false
const cfg = yield* Effect.promise(() => Config.get())
return cfg.snapshot !== false
}) })
const excludesPath = Effect.gen(function* () { const excludes = Effect.fnUntraced(function* () {
const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], { const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
cwd: worktree, cwd: worktree,
}) })
const file = result.text.trim() const file = result.text.trim()
if (!file) return undefined if (!file) return
if (!(yield* exists(file))) return undefined if (!(yield* exists(file))) return
return file return file
}) })
const syncExclude = Effect.gen(function* () { const sync = Effect.fnUntraced(function* () {
const file = yield* excludesPath const file = yield* excludes()
const target = path.join(snapshotGit, "info", "exclude") const target = path.join(gitdir, "info", "exclude")
yield* mkdir(path.join(snapshotGit, "info")) yield* mkdir(path.join(gitdir, "info"))
if (!file) { if (!file) {
yield* writeFile(target, "") yield* write(target, "")
return return
} }
const text = yield* readFile(file) yield* write(target, yield* read(file))
yield* writeFile(target, text)
}) })
const add = Effect.gen(function* () { const add = Effect.fnUntraced(function* () {
yield* syncExclude yield* sync()
yield* git([...GIT_CFG, ...gitArgs(["add", "."])], { cwd: directory }) yield* git([...cfg, ...args(["add", "."])], { cwd: directory })
}) })
// --- service methods --- const cleanup = Effect.fn("Snapshot.cleanup")(function* () {
if (!(yield* enabled())) return
const cleanup = Effect.fn("SnapshotService.cleanup")(function* () { if (!(yield* exists(gitdir))) return
if (!(yield* isEnabled)) return const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: directory })
if (!(yield* exists(snapshotGit))) return
const result = yield* git(gitArgs(["gc", `--prune=${PRUNE}`]), {
cwd: directory,
})
if (result.code !== 0) { if (result.code !== 0) {
log.warn("cleanup failed", { log.warn("cleanup failed", {
exitCode: result.code, exitCode: result.code,
@ -183,57 +172,55 @@ export class SnapshotService extends ServiceMap.Service<SnapshotService, Snapsho
}) })
return return
} }
log.info("cleanup", { prune: PRUNE }) log.info("cleanup", { prune })
}) })
const track = Effect.fn("SnapshotService.track")(function* () { const track = Effect.fn("Snapshot.track")(function* () {
if (!(yield* isEnabled)) return undefined if (!(yield* enabled())) return
const existed = yield* exists(snapshotGit) const existed = yield* exists(gitdir)
yield* mkdir(snapshotGit) yield* mkdir(gitdir)
if (!existed) { if (!existed) {
yield* git(["init"], { yield* git(["init"], {
env: { GIT_DIR: snapshotGit, GIT_WORK_TREE: worktree }, env: { GIT_DIR: gitdir, GIT_WORK_TREE: worktree },
}) })
yield* git(["--git-dir", snapshotGit, "config", "core.autocrlf", "false"]) yield* git(["--git-dir", gitdir, "config", "core.autocrlf", "false"])
yield* git(["--git-dir", snapshotGit, "config", "core.longpaths", "true"]) yield* git(["--git-dir", gitdir, "config", "core.longpaths", "true"])
yield* git(["--git-dir", snapshotGit, "config", "core.symlinks", "true"]) yield* git(["--git-dir", gitdir, "config", "core.symlinks", "true"])
yield* git(["--git-dir", snapshotGit, "config", "core.fsmonitor", "false"]) yield* git(["--git-dir", gitdir, "config", "core.fsmonitor", "false"])
log.info("initialized") log.info("initialized")
} }
yield* add yield* add()
const result = yield* git(gitArgs(["write-tree"]), { cwd: directory }) const result = yield* git(args(["write-tree"]), { cwd: directory })
const hash = result.text.trim() const hash = result.text.trim()
log.info("tracking", { hash, cwd: directory, git: snapshotGit }) log.info("tracking", { hash, cwd: directory, git: gitdir })
return hash return hash
}) })
const patch = Effect.fn("SnapshotService.patch")(function* (hash: string) { const patch = Effect.fn("Snapshot.patch")(function* (hash: string) {
yield* add yield* add()
const result = yield* git( const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])], {
[...GIT_CFG_QUOTE, ...gitArgs(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])], cwd: directory,
{ cwd: directory }, })
)
if (result.code !== 0) { if (result.code !== 0) {
log.warn("failed to get diff", { hash, exitCode: result.code }) log.warn("failed to get diff", { hash, exitCode: result.code })
return { hash, files: [] } return { hash, files: [] }
} }
return {
const files = result.text hash,
.trim() files: result.text
.split("\n") .trim()
.map((x: string) => x.trim()) .split("\n")
.filter(Boolean) .map((x) => x.trim())
.map((x: string) => path.join(worktree, x).replaceAll("\\", "/")) .filter(Boolean)
.map((x) => path.join(worktree, x).replaceAll("\\", "/")),
return { hash, files } }
}) })
const restore = Effect.fn("SnapshotService.restore")(function* (snapshot: string) { const restore = Effect.fn("Snapshot.restore")(function* (snapshot: string) {
log.info("restore", { commit: snapshot }) log.info("restore", { commit: snapshot })
const result = yield* git([...GIT_CORE, ...gitArgs(["read-tree", snapshot])], { cwd: worktree }) const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: worktree })
if (result.code === 0) { if (result.code === 0) {
const checkout = yield* git([...GIT_CORE, ...gitArgs(["checkout-index", "-a", "-f"])], { cwd: worktree }) const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], { cwd: worktree })
if (checkout.code === 0) return if (checkout.code === 0) return
log.error("failed to restore snapshot", { log.error("failed to restore snapshot", {
snapshot, snapshot,
@ -249,38 +236,34 @@ export class SnapshotService extends ServiceMap.Service<SnapshotService, Snapsho
}) })
}) })
const revert = Effect.fn("SnapshotService.revert")(function* (patches: Snapshot.Patch[]) { const revert = Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) {
const map = new Map(patches.flatMap((patch) => patch.files.map((file) => [file, patch] as const)))
const seen = new Set<string>() const seen = new Set<string>()
for (const item of patches) { for (const file of patches.flatMap((patch) => patch.files)) {
for (const file of item.files) { if (seen.has(file)) continue
if (seen.has(file)) continue const patch = map.get(file)
log.info("reverting", { file, hash: item.hash }) if (!patch) continue
const result = yield* git([...GIT_CORE, ...gitArgs(["checkout", item.hash, "--", file])], { log.info("reverting", { file, hash: patch.hash })
cwd: worktree, const result = yield* git([...core, ...args(["checkout", patch.hash, "--", file])], { cwd: worktree })
}) if (result.code !== 0) {
if (result.code !== 0) { const rel = path.relative(worktree, file)
const relativePath = path.relative(worktree, file) const tree = yield* git([...core, ...args(["ls-tree", patch.hash, "--", rel])], { cwd: worktree })
const checkTree = yield* git([...GIT_CORE, ...gitArgs(["ls-tree", item.hash, "--", relativePath])], { if (tree.code === 0 && tree.text.trim()) {
cwd: worktree, log.info("file existed in snapshot but checkout failed, keeping", { file })
}) } else {
if (checkTree.code === 0 && checkTree.text.trim()) { log.info("file did not exist in snapshot, deleting", { file })
log.info("file existed in snapshot but checkout failed, keeping", { file }) yield* remove(file)
} else {
log.info("file did not exist in snapshot, deleting", { file })
yield* removeFile(file)
}
} }
seen.add(file)
} }
seen.add(file)
} }
}) })
const diff = Effect.fn("SnapshotService.diff")(function* (hash: string) { const diff = Effect.fn("Snapshot.diff")(function* (hash: string) {
yield* add yield* add()
const result = yield* git([...GIT_CFG_QUOTE, ...gitArgs(["diff", "--no-ext-diff", hash, "--", "."])], { const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", hash, "--", "."])], {
cwd: worktree, cwd: worktree,
}) })
if (result.code !== 0) { if (result.code !== 0) {
log.warn("failed to get diff", { log.warn("failed to get diff", {
hash, hash,
@ -289,19 +272,15 @@ export class SnapshotService extends ServiceMap.Service<SnapshotService, Snapsho
}) })
return "" return ""
} }
return result.text.trim() return result.text.trim()
}) })
const diffFull = Effect.fn("SnapshotService.diffFull")(function* (from: string, to: string) { const diffFull = Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
const result: Snapshot.FileDiff[] = [] const result: Snapshot.FileDiff[] = []
const status = new Map<string, "added" | "deleted" | "modified">() const status = new Map<string, "added" | "deleted" | "modified">()
const statuses = yield* git( const statuses = yield* git(
[ [...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
...GIT_CFG_QUOTE,
...gitArgs(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."]),
],
{ cwd: directory }, { cwd: directory },
) )
@ -309,43 +288,45 @@ export class SnapshotService extends ServiceMap.Service<SnapshotService, Snapsho
if (!line) continue if (!line) continue
const [code, file] = line.split("\t") const [code, file] = line.split("\t")
if (!code || !file) continue if (!code || !file) continue
const kind = code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified" status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
status.set(file, kind)
} }
const numstat = yield* git( const numstat = yield* git(
[...GIT_CFG_QUOTE, ...gitArgs(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])], [...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
{ cwd: directory }, {
cwd: directory,
},
) )
for (const line of numstat.text.trim().split("\n")) { for (const line of numstat.text.trim().split("\n")) {
if (!line) continue if (!line) continue
const [additions, deletions, file] = line.split("\t") const [adds, dels, file] = line.split("\t")
const isBinaryFile = additions === "-" && deletions === "-" if (!file) continue
const [before, after] = isBinaryFile const binary = adds === "-" && dels === "-"
const [before, after] = binary
? ["", ""] ? ["", ""]
: yield* Effect.all( : yield* Effect.all(
[ [
git([...GIT_CFG, ...gitArgs(["show", `${from}:${file}`])]).pipe(Effect.map((r) => r.text)), git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)),
git([...GIT_CFG, ...gitArgs(["show", `${to}:${file}`])]).pipe(Effect.map((r) => r.text)), git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)),
], ],
{ concurrency: 2 }, { concurrency: 2 },
) )
const added = isBinaryFile ? 0 : parseInt(additions!) const additions = binary ? 0 : parseInt(adds)
const deleted = isBinaryFile ? 0 : parseInt(deletions!) const deletions = binary ? 0 : parseInt(dels)
result.push({ result.push({
file: file!, file,
before, before,
after, after,
additions: Number.isFinite(added) ? added : 0, additions: Number.isFinite(additions) ? additions : 0,
deletions: Number.isFinite(deleted) ? deleted : 0, deletions: Number.isFinite(deletions) ? deletions : 0,
status: status.get(file!) ?? "modified", status: status.get(file) ?? "modified",
}) })
} }
return result return result
}) })
// Start delayed hourly cleanup fiber — scoped to instance lifetime
yield* cleanup().pipe( yield* cleanup().pipe(
Effect.catchCause((cause) => { Effect.catchCause((cause) => {
log.error("cleanup loop failed", { cause: Cause.pretty(cause) }) log.error("cleanup loop failed", { cause: Cause.pretty(cause) })
@ -356,17 +337,11 @@ export class SnapshotService extends ServiceMap.Service<SnapshotService, Snapsho
Effect.forkScoped, Effect.forkScoped,
) )
return SnapshotService.of({ return Service.of({ cleanup, track, patch, restore, revert, diff, diffFull })
cleanup,
track,
patch,
restore,
revert,
diff,
diffFull,
})
}), }),
).pipe( )
export const defaultLayer = layer.pipe(
Layer.provide(NodeChildProcessSpawner.layer), Layer.provide(NodeChildProcessSpawner.layer),
Layer.provide(NodeFileSystem.layer), Layer.provide(NodeFileSystem.layer),
Layer.provide(NodePath.layer), Layer.provide(NodePath.layer),

View file

@ -30,7 +30,7 @@ export namespace TruncateEffect {
return PermissionEffect.evaluate("task", "*", agent.permission).action !== "deny" return PermissionEffect.evaluate("task", "*", agent.permission).action !== "deny"
} }
export interface Api { export interface Interface {
readonly cleanup: () => Effect.Effect<void> readonly cleanup: () => Effect.Effect<void>
/** /**
* Returns output unchanged when it fits within the limits, otherwise writes the full text * Returns output unchanged when it fits within the limits, otherwise writes the full text
@ -39,14 +39,14 @@ export namespace TruncateEffect {
readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect<Result> readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect<Result>
} }
export class Service extends ServiceMap.Service<Service, Api>()("@opencode/Truncate") {} export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Truncate") {}
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem const fs = yield* FileSystem.FileSystem
const cleanup = Effect.fn("TruncateEffect.cleanup")(function* () { const cleanup = Effect.fn("Truncate.cleanup")(function* () {
const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - Duration.toMillis(RETENTION))) const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - Duration.toMillis(RETENTION)))
const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe( const entries = yield* fs.readDirectory(TRUNCATION_DIR).pipe(
Effect.map((all) => all.filter((name) => name.startsWith("tool_"))), Effect.map((all) => all.filter((name) => name.startsWith("tool_"))),
@ -58,7 +58,7 @@ export namespace TruncateEffect {
} }
}) })
const output = Effect.fn("TruncateEffect.output")(function* ( const output = Effect.fn("Truncate.output")(function* (
text: string, text: string,
options: Options = {}, options: Options = {},
agent?: Agent.Info, agent?: Agent.Info,

View file

@ -5,7 +5,7 @@ import path from "path"
import { Deferred, Effect, Fiber, Option } from "effect" import { Deferred, Effect, Fiber, Option } from "effect"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { watcherConfigLayer, withServices } from "../fixture/instance" import { watcherConfigLayer, withServices } from "../fixture/instance"
import { FileWatcher, FileWatcherService } from "../../src/file/watcher" import { FileWatcher } from "../../src/file/watcher"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { GlobalBus } from "../../src/bus/global" import { GlobalBus } from "../../src/bus/global"
@ -19,13 +19,13 @@ const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? desc
type BusUpdate = { directory?: string; payload: { type: string; properties: WatcherEvent } } type BusUpdate = { directory?: string; payload: { type: string; properties: WatcherEvent } }
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" } type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
/** Run `body` with a live FileWatcherService. */ /** Run `body` with a live FileWatcher service. */
function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) { function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
return withServices( return withServices(
directory, directory,
FileWatcherService.layer, FileWatcher.layer,
async (rt) => { async (rt) => {
await rt.runPromise(FileWatcherService.use((s) => s.init())) await rt.runPromise(FileWatcher.Service.use(() => Effect.void))
await Effect.runPromise(ready(directory)) await Effect.runPromise(ready(directory))
await Effect.runPromise(body) await Effect.runPromise(body)
}, },
@ -138,7 +138,7 @@ function ready(directory: string) {
// Tests // Tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describeWatcher("FileWatcherService", () => { describeWatcher("FileWatcher", () => {
afterEach(() => Instance.disposeAll()) afterEach(() => Instance.disposeAll())
test("publishes root create, update, and delete events", async () => { test("publishes root create, update, and delete events", async () => {

View file

@ -1,17 +1,18 @@
import { Effect } from "effect"
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { withServices } from "../fixture/instance" import { withServices } from "../fixture/instance"
import { FormatService } from "../../src/format" import { Format } from "../../src/format"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
describe("FormatService", () => { describe("Format", () => {
afterEach(() => Instance.disposeAll()) afterEach(() => Instance.disposeAll())
test("status() returns built-in formatters when no config overrides", async () => { test("status() returns built-in formatters when no config overrides", async () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
await withServices(tmp.path, FormatService.layer, async (rt) => { await withServices(tmp.path, Format.layer, async (rt) => {
const statuses = await rt.runPromise(FormatService.use((s) => s.status())) const statuses = await rt.runPromise(Format.Service.use((s) => s.status()))
expect(Array.isArray(statuses)).toBe(true) expect(Array.isArray(statuses)).toBe(true)
expect(statuses.length).toBeGreaterThan(0) expect(statuses.length).toBeGreaterThan(0)
@ -32,8 +33,8 @@ describe("FormatService", () => {
config: { formatter: false }, config: { formatter: false },
}) })
await withServices(tmp.path, FormatService.layer, async (rt) => { await withServices(tmp.path, Format.layer, async (rt) => {
const statuses = await rt.runPromise(FormatService.use((s) => s.status())) const statuses = await rt.runPromise(Format.Service.use((s) => s.status()))
expect(statuses).toEqual([]) expect(statuses).toEqual([])
}) })
}) })
@ -47,18 +48,18 @@ describe("FormatService", () => {
}, },
}) })
await withServices(tmp.path, FormatService.layer, async (rt) => { await withServices(tmp.path, Format.layer, async (rt) => {
const statuses = await rt.runPromise(FormatService.use((s) => s.status())) const statuses = await rt.runPromise(Format.Service.use((s) => s.status()))
const gofmt = statuses.find((s) => s.name === "gofmt") const gofmt = statuses.find((s) => s.name === "gofmt")
expect(gofmt).toBeUndefined() expect(gofmt).toBeUndefined()
}) })
}) })
test("init() completes without error", async () => { test("service initializes without error", async () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
await withServices(tmp.path, FormatService.layer, async (rt) => { await withServices(tmp.path, Format.layer, async (rt) => {
await rt.runPromise(FormatService.use((s) => s.init())) await rt.runPromise(Format.Service.use(() => Effect.void))
}) })
}) })
}) })

View file

@ -2,13 +2,13 @@ import { $ } from "bun"
import { afterEach, describe, expect, test } from "bun:test" import { afterEach, describe, expect, test } from "bun:test"
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { Layer, ManagedRuntime } from "effect" import { Effect, Layer, ManagedRuntime } from "effect"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { watcherConfigLayer, withServices } from "../fixture/instance" import { watcherConfigLayer, withServices } from "../fixture/instance"
import { FileWatcher, FileWatcherService } from "../../src/file/watcher" import { FileWatcher } from "../../src/file/watcher"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { GlobalBus } from "../../src/bus/global" import { GlobalBus } from "../../src/bus/global"
import { Vcs, VcsService } from "../../src/project/vcs" import { Vcs } from "../../src/project/vcs"
// Skip in CI — native @parcel/watcher binding needed // Skip in CI — native @parcel/watcher binding needed
const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
@ -19,14 +19,14 @@ const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe
function withVcs( function withVcs(
directory: string, directory: string,
body: (rt: ManagedRuntime.ManagedRuntime<FileWatcherService | VcsService, never>) => Promise<void>, body: (rt: ManagedRuntime.ManagedRuntime<FileWatcher.Service | Vcs.Service, never>) => Promise<void>,
) { ) {
return withServices( return withServices(
directory, directory,
Layer.merge(FileWatcherService.layer, VcsService.layer), Layer.merge(FileWatcher.layer, Vcs.layer),
async (rt) => { async (rt) => {
await rt.runPromise(FileWatcherService.use((s) => s.init())) await rt.runPromise(FileWatcher.Service.use(() => Effect.void))
await rt.runPromise(VcsService.use((s) => s.init())) await rt.runPromise(Vcs.Service.use(() => Effect.void))
await Bun.sleep(200) await Bun.sleep(200)
await body(rt) await body(rt)
}, },
@ -67,7 +67,7 @@ describeVcs("Vcs", () => {
await using tmp = await tmpdir({ git: true }) await using tmp = await tmpdir({ git: true })
await withVcs(tmp.path, async (rt) => { await withVcs(tmp.path, async (rt) => {
const branch = await rt.runPromise(VcsService.use((s) => s.branch())) const branch = await rt.runPromise(Vcs.Service.use((s) => s.branch()))
expect(branch).toBeDefined() expect(branch).toBeDefined()
expect(typeof branch).toBe("string") expect(typeof branch).toBe("string")
}) })
@ -77,7 +77,7 @@ describeVcs("Vcs", () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
await withVcs(tmp.path, async (rt) => { await withVcs(tmp.path, async (rt) => {
const branch = await rt.runPromise(VcsService.use((s) => s.branch())) const branch = await rt.runPromise(Vcs.Service.use((s) => s.branch()))
expect(branch).toBeUndefined() expect(branch).toBeUndefined()
}) })
}) })
@ -110,7 +110,7 @@ describeVcs("Vcs", () => {
await fs.writeFile(head, `ref: refs/heads/${branch}\n`) await fs.writeFile(head, `ref: refs/heads/${branch}\n`)
await pending await pending
const current = await rt.runPromise(VcsService.use((s) => s.branch())) const current = await rt.runPromise(Vcs.Service.use((s) => s.branch()))
expect(current).toBe(branch) expect(current).toBe(branch)
}) })
}) })

View file

@ -1,6 +1,6 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test" import { describe, test, expect, beforeAll, afterAll } from "bun:test"
import { Effect } from "effect" import { Effect } from "effect"
import { DiscoveryService } from "../../src/skill/discovery" import { Discovery } from "../../src/skill/discovery"
import { Global } from "../../src/global" import { Global } from "../../src/global"
import { Filesystem } from "../../src/util/filesystem" import { Filesystem } from "../../src/util/filesystem"
import { rm } from "fs/promises" import { rm } from "fs/promises"
@ -48,7 +48,7 @@ afterAll(async () => {
describe("Discovery.pull", () => { describe("Discovery.pull", () => {
const pull = (url: string) => const pull = (url: string) =>
Effect.runPromise(DiscoveryService.use((s) => s.pull(url)).pipe(Effect.provide(DiscoveryService.defaultLayer))) Effect.runPromise(Discovery.Service.use((s) => s.pull(url)).pipe(Effect.provide(Discovery.defaultLayer)))
test("downloads skills from cloudflare url", async () => { test("downloads skills from cloudflare url", async () => {
const dirs = await pull(CLOUDFLARE_SKILLS_URL) const dirs = await pull(CLOUDFLARE_SKILLS_URL)