From b9de3ad370642a21ce4d6e780bc8bc1f1e124563 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 18 Mar 2026 20:57:08 -0400 Subject: [PATCH 1/5] fix(bus): tighten GlobalBus payload and BusEvent.define types Constrain BusEvent.define to ZodObject instead of ZodType so TS knows event properties are always a record. Type GlobalBus payload as { type: string; properties: Record } instead of any. Refactor watcher test to use Bus.subscribe instead of raw GlobalBus listener, removing hand-rolled event types and unnecessary casts. --- packages/opencode/src/bus/bus-event.ts | 4 ++-- packages/opencode/src/bus/global.ts | 2 +- .../opencode/src/control-plane/workspace.ts | 2 +- packages/opencode/test/file/watcher.test.ts | 20 +++++++------------ 4 files changed, 11 insertions(+), 17 deletions(-) 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/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index c3c28ed605..d1f884f35a 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -123,7 +123,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/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) { From d3972f71071bc40f9b12df693575367567c11738 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 19 Mar 2026 16:14:48 -0400 Subject: [PATCH 2/5] use forkScoped + Fiber.join for lazy Skill init (replace ensure pattern) --- packages/opencode/src/skill/skill.ts | 132 ++++++++++++--------------- 1 file changed, 59 insertions(+), 73 deletions(-) diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index d7aeb911f3..91454d1532 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -2,7 +2,7 @@ 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" @@ -54,11 +54,6 @@ export namespace Skill { type State = { skills: Record dirs: Set - task?: Promise - } - - type Cache = State & { - ensure: () => Promise } export interface Interface { @@ -116,68 +111,6 @@ export namespace Skill { }) } - // 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( @@ -185,25 +118,78 @@ export namespace Skill { Effect.gen(function* () { const instance = yield* InstanceContext const discovery = yield* Discovery.Service - const state = create(instance, discovery) + const state: State = { + skills: {}, + dirs: new Set(), + } + + const load = Effect.fn("Skill.load")(function* () { + yield* Effect.promise(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 loadFiber = yield* load().pipe( + Effect.catchCause(() => Effect.void), + Effect.forkScoped, + ) const get = Effect.fn("Skill.get")(function* (name: string) { - yield* Effect.promise(() => state.ensure()) + yield* Fiber.join(loadFiber) return state.skills[name] }) const all = Effect.fn("Skill.all")(function* () { - yield* Effect.promise(() => state.ensure()) + yield* Fiber.join(loadFiber) return Object.values(state.skills) }) const dirs = Effect.fn("Skill.dirs")(function* () { - yield* Effect.promise(() => state.ensure()) + yield* Fiber.join(loadFiber) return Array.from(state.dirs) }) const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) { - yield* Effect.promise(() => state.ensure()) + yield* Fiber.join(loadFiber) const list = Object.values(state.skills) if (!agent) return list return list.filter((skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny") From 5f5546ee9bca4f6a10751694ec62c9ac1bd1c0f7 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 19 Mar 2026 16:21:57 -0400 Subject: [PATCH 3/5] log errors in catchCause instead of silently swallowing --- packages/opencode/src/skill/skill.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index 91454d1532..206327acb5 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -169,7 +169,7 @@ export namespace Skill { }) const loadFiber = yield* load().pipe( - Effect.catchCause(() => Effect.void), + Effect.catchCause((cause) => Effect.sync(() => log.error("init failed", { cause }))), Effect.forkScoped, ) From 47cb07a8cf5b33c4eec44039013c1b4dbb281dda Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 19 Mar 2026 16:38:04 -0400 Subject: [PATCH 4/5] effectify Skill.load: replace Effect.promise blob with native Effect operations Convert add/scan/load from async functions wrapped in Effect.promise to proper Effect.fn generators using AppFileSystem.Service for isDir, glob, and up operations. This eliminates the nested Effect.runPromise call for discovery.pull and enables concurrent skill file processing. --- packages/opencode/src/skill/skill.ts | 232 +++++++++++++++------------ 1 file changed, 128 insertions(+), 104 deletions(-) diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index 206327acb5..55bfad92a7 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -6,15 +6,14 @@ 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" import { Global } from "@/global" import { PermissionNext } from "@/permission" -import { Filesystem } from "@/util/filesystem" import { Config } from "../config/config" import { ConfigMarkdown } from "../config/markdown" -import { Glob } from "../util/glob" import { Log } from "../util/log" import { Discovery } from "./discovery" @@ -63,144 +62,169 @@ 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 }) - }) - } - 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: State = { - skills: {}, - dirs: new Set(), - } + 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 load = Effect.fn("Skill.load")(function* () { - yield* Effect.promise(async () => { + const state: State = { + skills: {}, + dirs: new Set(), + } + + 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 + }), + ), + ) + + 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 = 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.orDie) + + yield* Effect.forEach(matches, (match) => add(match), { concurrency: "unbounded" }).pipe( + Effect.catch((error) => { + if (!opts?.scope) return Effect.die(error) + return Effect.sync(() => log.error(`failed to scan ${opts.scope} skills`, { dir: root, error })) + }), + ) + }) + + 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 (!(await Filesystem.isDir(root))) continue - await scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" }) + if (!(yield* fs.isDir(root).pipe(Effect.orDie))) continue + yield* scan(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" }) - } + // 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" }, + ) } - for (const dir of await Config.directories()) { - await scan(state, dir, OPENCODE_SKILL_PATTERN) - } + // Phase 3: Config directories + const dirs = yield* Effect.promise(() => Config.directories()) + yield* Effect.forEach(dirs, (dir) => scan(dir, OPENCODE_SKILL_PATTERN), { concurrency: "unbounded" }) - const cfg = await Config.get() + // Phase 4: Custom paths + const cfg = yield* Effect.promise(() => 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))) { + if (!(yield* fs.isDir(dir).pipe(Effect.orDie))) { log.warn("skill path not found", { path: dir }) continue } - await scan(state, dir, SKILL_PATTERN) + yield* scan(dir, SKILL_PATTERN) } + // Phase 5: Remote URLs for (const url of cfg.skills?.urls ?? []) { - for (const dir of await Effect.runPromise(discovery.pull(url))) { + const pullDirs = yield* discovery.pull(url) + for (const dir of pullDirs) { state.dirs.add(dir) - await scan(state, dir, SKILL_PATTERN) + 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 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 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 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 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) - if (!agent) return list - return list.filter((skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny") - }) + const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) { + yield* Fiber.join(loadFiber) + 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 }) - }), - ) + 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) { From be7a9c4987d43bb3daef720979e8698ce5359cad Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 19 Mar 2026 16:54:06 -0400 Subject: [PATCH 5/5] extract expandHome utility, fix scan error handling scope --- packages/opencode/src/config/paths.ts | 5 +---- packages/opencode/src/session/instruction.ts | 4 +--- packages/opencode/src/session/prompt.ts | 2 +- packages/opencode/src/skill/skill.ts | 21 +++++++++++--------- packages/opencode/src/util/filesystem.ts | 5 +++++ 5 files changed, 20 insertions(+), 17 deletions(-) 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/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 36162656aa..0f130c9d70 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -202,7 +202,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 55bfad92a7..e8800f437b 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -1,4 +1,3 @@ -import os from "os" import path from "path" import { pathToFileURL } from "url" import z from "zod" @@ -12,6 +11,7 @@ import { runPromiseInstance } from "@/effect/runtime" import { Flag } from "@/flag/flag" import { Global } from "@/global" import { PermissionNext } from "@/permission" +import { Filesystem } from "@/util/filesystem" import { Config } from "../config/config" import { ConfigMarkdown } from "../config/markdown" import { Log } from "../util/log" @@ -127,14 +127,17 @@ export namespace Skill { symlink: true, dot: opts?.dot, }) - .pipe(Effect.orDie) + .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" }).pipe( - Effect.catch((error) => { - if (!opts?.scope) return Effect.die(error) - return Effect.sync(() => log.error(`failed to scan ${opts.scope} skills`, { dir: root, error })) - }), - ) + yield* Effect.forEach(matches, (match) => add(match), { concurrency: "unbounded" }) }) const load = Effect.fn("Skill.load")(function* () { @@ -169,7 +172,7 @@ export namespace Skill { // Phase 4: Custom paths const cfg = yield* Effect.promise(() => Config.get()) for (const item of cfg.skills?.paths ?? []) { - const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item + 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 }) 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" }