diff --git a/packages/opencode/src/bus/bus-event.ts b/packages/opencode/src/bus/bus-event.ts index 7fe13833c8..1d9a31d4a2 100644 --- a/packages/opencode/src/bus/bus-event.ts +++ b/packages/opencode/src/bus/bus-event.ts @@ -1,5 +1,5 @@ import z from "zod" -import type { ZodType } from "zod" +import type { ZodObject, ZodRawShape } from "zod" import { Log } from "../util/log" export namespace BusEvent { @@ -9,7 +9,7 @@ export namespace BusEvent { const registry = new Map() - export function define(type: Type, properties: Properties) { + export function define>(type: Type, properties: Properties) { const result = { type, properties, diff --git a/packages/opencode/src/bus/global.ts b/packages/opencode/src/bus/global.ts index 43386dd6b2..dcc7664007 100644 --- a/packages/opencode/src/bus/global.ts +++ b/packages/opencode/src/bus/global.ts @@ -4,7 +4,7 @@ export const GlobalBus = new EventEmitter<{ event: [ { directory?: string - payload: any + payload: { type: string; properties: Record } }, ] }>() diff --git a/packages/opencode/src/config/paths.ts b/packages/opencode/src/config/paths.ts index 396417e9a5..3b3876d2a2 100644 --- a/packages/opencode/src/config/paths.ts +++ b/packages/opencode/src/config/paths.ts @@ -1,5 +1,4 @@ import path from "path" -import os from "os" import z from "zod" import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser" import { NamedError } from "@opencode-ai/util/error" @@ -109,9 +108,7 @@ export namespace ConfigPaths { } let filePath = token.replace(/^\{file:/, "").replace(/\}$/, "") - if (filePath.startsWith("~/")) { - filePath = path.join(os.homedir(), filePath.slice(2)) - } + filePath = Filesystem.expandHome(filePath) const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath) const fileContent = ( diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index e5294844b1..b519895b2f 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -124,7 +124,7 @@ export namespace Workspace { await parseSSE(res.body, stop, (event) => { GlobalBus.emit("event", { directory: space.id, - payload: event, + payload: event as { type: string; properties: Record }, }) }) // Wait 250ms and retry if SSE connection fails diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index 86f73d0fd2..b482d761c9 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -95,9 +95,7 @@ export namespace InstructionPrompt { if (config.instructions) { for (let instruction of config.instructions) { if (instruction.startsWith("https://") || instruction.startsWith("http://")) continue - if (instruction.startsWith("~/")) { - instruction = path.join(os.homedir(), instruction.slice(2)) - } + instruction = Filesystem.expandHome(instruction) const matches = path.isAbsolute(instruction) ? await Glob.scan(path.basename(instruction), { cwd: path.dirname(instruction), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index bac958ec10..d1c9684402 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -203,7 +203,7 @@ export namespace SessionPrompt { if (seen.has(name)) return seen.add(name) const filepath = name.startsWith("~/") - ? path.join(os.homedir(), name.slice(2)) + ? Filesystem.expandHome(name) : path.resolve(Instance.worktree, name) const stats = await fs.stat(filepath).catch(() => undefined) diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index 5339691a01..1ade02e2b5 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -1,11 +1,11 @@ -import os from "os" import path from "path" import { pathToFileURL } from "url" import z from "zod" -import { Effect, Layer, ServiceMap } from "effect" +import { Effect, Fiber, Layer, ServiceMap } from "effect" import { NamedError } from "@opencode-ai/util/error" import type { Agent } from "@/agent/agent" import { Bus } from "@/bus" +import { AppFileSystem } from "@/filesystem" import { InstanceContext } from "@/effect/instance-context" import { runPromiseInstance } from "@/effect/runtime" import { Flag } from "@/flag/flag" @@ -14,7 +14,6 @@ import { PermissionNext } from "@/permission" import { Filesystem } from "@/util/filesystem" import { Config } from "../config/config" import { ConfigMarkdown } from "../config/markdown" -import { Glob } from "../util/glob" import { Log } from "../util/log" import { Discovery } from "./discovery" @@ -54,11 +53,6 @@ export namespace Skill { type State = { skills: Record dirs: Set - task?: Promise - } - - type Cache = State & { - ensure: () => Promise } export interface Interface { @@ -68,153 +62,172 @@ export namespace Skill { readonly available: (agent?: Agent.Info) => Effect.Effect } - const add = async (state: State, match: string) => { - const md = await ConfigMarkdown.parse(match).catch(async (err) => { - const message = ConfigMarkdown.FrontmatterError.isInstance(err) - ? err.data.message - : `Failed to parse skill ${match}` - const { Session } = await import("@/session") - Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) - log.error("failed to load skill", { skill: match, err }) - return undefined - }) - - if (!md) return - - const parsed = Info.pick({ name: true, description: true }).safeParse(md.data) - if (!parsed.success) return - - if (state.skills[parsed.data.name]) { - log.warn("duplicate skill name", { - name: parsed.data.name, - existing: state.skills[parsed.data.name].location, - duplicate: match, - }) - } - - state.dirs.add(path.dirname(match)) - state.skills[parsed.data.name] = { - name: parsed.data.name, - description: parsed.data.description, - location: match, - content: md.content, - } - } - - const scan = async (state: State, root: string, pattern: string, opts?: { dot?: boolean; scope?: string }) => { - return Glob.scan(pattern, { - cwd: root, - absolute: true, - include: "file", - symlink: true, - dot: opts?.dot, - }) - .then((matches) => Promise.all(matches.map((match) => add(state, match)))) - .catch((error) => { - if (!opts?.scope) throw error - log.error(`failed to scan ${opts.scope} skills`, { dir: root, error }) - }) - } - - // TODO: Migrate to Effect - const create = (instance: InstanceContext.Shape, discovery: Discovery.Interface): Cache => { - const state: State = { - skills: {}, - dirs: new Set(), - } - - const load = async () => { - if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) { - for (const dir of EXTERNAL_DIRS) { - const root = path.join(Global.Path.home, dir) - if (!(await Filesystem.isDir(root))) continue - await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" }) - } - - for await (const root of Filesystem.up({ - targets: EXTERNAL_DIRS, - start: instance.directory, - stop: instance.project.worktree, - })) { - await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" }) - } - } - - for (const dir of await Config.directories()) { - await scan(state, dir, OPENCODE_SKILL_PATTERN) - } - - const cfg = await Config.get() - for (const item of cfg.skills?.paths ?? []) { - const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item - const dir = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded) - if (!(await Filesystem.isDir(dir))) { - log.warn("skill path not found", { path: dir }) - continue - } - - await scan(state, dir, SKILL_PATTERN) - } - - for (const url of cfg.skills?.urls ?? []) { - for (const dir of await Effect.runPromise(discovery.pull(url))) { - state.dirs.add(dir) - await scan(state, dir, SKILL_PATTERN) - } - } - - log.info("init", { count: Object.keys(state.skills).length }) - } - - const ensure = () => { - if (state.task) return state.task - state.task = load().catch((err) => { - state.task = undefined - throw err - }) - return state.task - } - - return { ...state, ensure } - } - export class Service extends ServiceMap.Service()("@opencode/Skill") {} - export const layer: Layer.Layer = Layer.effect( - Service, - Effect.gen(function* () { - const instance = yield* InstanceContext - const discovery = yield* Discovery.Service - const state = create(instance, discovery) + export const layer: Layer.Layer = + Layer.effect( + Service, + Effect.gen(function* () { + const instance = yield* InstanceContext + const discovery = yield* Discovery.Service + const fs = yield* AppFileSystem.Service - const get = Effect.fn("Skill.get")(function* (name: string) { - yield* Effect.promise(() => state.ensure()) - return state.skills[name] - }) + const state: State = { + skills: {}, + dirs: new Set(), + } - const all = Effect.fn("Skill.all")(function* () { - yield* Effect.promise(() => state.ensure()) - return Object.values(state.skills) - }) + const add = Effect.fn("Skill.add")(function* (match: string) { + const md = yield* Effect.tryPromise(() => ConfigMarkdown.parse(match)).pipe( + Effect.catch((err) => + Effect.gen(function* () { + const message = ConfigMarkdown.FrontmatterError.isInstance(err) + ? err.data.message + : `Failed to parse skill ${match}` + const { Session } = yield* Effect.promise(() => import("@/session")) + Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() }) + log.error("failed to load skill", { skill: match, err }) + return undefined + }), + ), + ) - const dirs = Effect.fn("Skill.dirs")(function* () { - yield* Effect.promise(() => state.ensure()) - return Array.from(state.dirs) - }) + if (!md) return - const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) { - yield* Effect.promise(() => state.ensure()) - const list = Object.values(state.skills).toSorted((a, b) => a.name.localeCompare(b.name)) - if (!agent) return list - return list.filter((skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny") - }) + const parsed = Info.pick({ name: true, description: true }).safeParse(md.data) + if (!parsed.success) return - return Service.of({ get, all, dirs, available }) - }), - ) + 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 = Effect.fn("Skill.scan")(function* ( + root: string, + pattern: string, + opts?: { dot?: boolean; scope?: string }, + ) { + const matches = yield* fs + .glob(pattern, { + cwd: root, + absolute: true, + include: "file", + symlink: true, + dot: opts?.dot, + }) + .pipe( + Effect.catch((error) => { + if (!opts?.scope) return Effect.fail(error) + return Effect.sync(() => { + log.error(`failed to scan ${opts.scope} skills`, { dir: root, error }) + return [] as string[] + }) + }), + ) + + yield* Effect.forEach(matches, (match) => add(match), { concurrency: "unbounded" }) + }) + + const load = Effect.fn("Skill.load")(function* () { + // Phase 1: External dirs (global) + if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) { + for (const dir of EXTERNAL_DIRS) { + const root = path.join(Global.Path.home, dir) + if (!(yield* fs.isDir(root).pipe(Effect.orDie))) continue + yield* scan(root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" }) + } + + // Phase 2: External dirs (project, walk up) + const roots = yield* fs + .up({ + targets: EXTERNAL_DIRS, + start: instance.directory, + stop: instance.project.worktree, + }) + .pipe(Effect.orDie) + + yield* Effect.forEach( + roots, + (root) => scan(root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" }), + { concurrency: "unbounded" }, + ) + } + + // Phase 3: Config directories + const dirs = yield* Effect.promise(() => Config.directories()) + yield* Effect.forEach(dirs, (dir) => scan(dir, OPENCODE_SKILL_PATTERN), { concurrency: "unbounded" }) + + // Phase 4: Custom paths + const cfg = yield* Effect.promise(() => Config.get()) + for (const item of cfg.skills?.paths ?? []) { + const expanded = Filesystem.expandHome(item) + const dir = path.isAbsolute(expanded) ? expanded : path.join(instance.directory, expanded) + if (!(yield* fs.isDir(dir).pipe(Effect.orDie))) { + log.warn("skill path not found", { path: dir }) + continue + } + + yield* scan(dir, SKILL_PATTERN) + } + + // Phase 5: Remote URLs + for (const url of cfg.skills?.urls ?? []) { + const pullDirs = yield* discovery.pull(url) + for (const dir of pullDirs) { + state.dirs.add(dir) + yield* scan(dir, SKILL_PATTERN) + } + } + + log.info("init", { count: Object.keys(state.skills).length }) + }) + + const loadFiber = yield* load().pipe( + Effect.catchCause((cause) => Effect.sync(() => log.error("init failed", { cause }))), + Effect.forkScoped, + ) + + const get = Effect.fn("Skill.get")(function* (name: string) { + yield* Fiber.join(loadFiber) + return state.skills[name] + }) + + const all = Effect.fn("Skill.all")(function* () { + yield* Fiber.join(loadFiber) + return Object.values(state.skills) + }) + + const dirs = Effect.fn("Skill.dirs")(function* () { + yield* Fiber.join(loadFiber) + return Array.from(state.dirs) + }) + + const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) { + yield* Fiber.join(loadFiber) + const list = Object.values(state.skills).toSorted((a, b) => a.name.localeCompare(b.name)) + if (!agent) return list + return list.filter((skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny") + }) + + return Service.of({ get, all, dirs, available }) + }), + ) export const defaultLayer: Layer.Layer = layer.pipe( Layer.provide(Discovery.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), ) export async function get(name: string) { diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index 37f00c6b9c..833826c7e3 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -2,6 +2,7 @@ import { chmod, mkdir, readFile, writeFile } from "fs/promises" import { createWriteStream, existsSync, statSync } from "fs" import { lookup } from "mime-types" import { realpathSync } from "fs" +import os from "os" import { dirname, join, relative, resolve as pathResolve } from "path" import { Readable } from "stream" import { pipeline } from "stream/promises" @@ -95,6 +96,10 @@ export namespace Filesystem { } } + export function expandHome(p: string): string { + return p.startsWith("~/") ? join(os.homedir(), p.slice(2)) : p + } + export function mimeType(p: string): string { return lookup(p) || "application/octet-stream" } diff --git a/packages/opencode/test/file/watcher.test.ts b/packages/opencode/test/file/watcher.test.ts index 2cd27643e8..6d4c5f402f 100644 --- a/packages/opencode/test/file/watcher.test.ts +++ b/packages/opencode/test/file/watcher.test.ts @@ -5,9 +5,9 @@ import path from "path" import { Deferred, Effect, Option } from "effect" import { tmpdir } from "../fixture/fixture" import { watcherConfigLayer, withServices } from "../fixture/instance" +import { Bus } from "../../src/bus" import { FileWatcher } from "../../src/file/watcher" import { Instance } from "../../src/project/instance" -import { GlobalBus } from "../../src/bus/global" // Native @parcel/watcher bindings aren't reliably available in CI (missing on Linux, flaky on Windows) const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip @@ -16,7 +16,6 @@ const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? desc // Helpers // --------------------------------------------------------------------------- -type BusUpdate = { directory?: string; payload: { type: string; properties: WatcherEvent } } type WatcherEvent = { file: string; event: "add" | "change" | "unlink" } /** Run `body` with a live FileWatcher service. */ @@ -36,22 +35,17 @@ function withWatcher(directory: string, body: Effect.Effect) { function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (evt: WatcherEvent) => void) { let done = false - function on(evt: BusUpdate) { + const unsub = Bus.subscribe(FileWatcher.Event.Updated, (evt) => { if (done) return - if (evt.directory !== directory) return - if (evt.payload.type !== FileWatcher.Event.Updated.type) return - if (!check(evt.payload.properties)) return - hit(evt.payload.properties) - } + if (!check(evt.properties)) return + hit(evt.properties) + }) - function cleanup() { + return () => { if (done) return done = true - GlobalBus.off("event", on) + unsub() } - - GlobalBus.on("event", on) - return cleanup } function wait(directory: string, check: (evt: WatcherEvent) => boolean) {