Compare commits

...

4 Commits

Author SHA1 Message Date
Kit Langton
be7a9c4987 extract expandHome utility, fix scan error handling scope 2026-03-19 16:54:06 -04:00
Kit Langton
47cb07a8cf 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.
2026-03-19 16:38:04 -04:00
Kit Langton
5f5546ee9b log errors in catchCause instead of silently swallowing 2026-03-19 16:21:57 -04:00
Kit Langton
d3972f7107 use forkScoped + Fiber.join for lazy Skill init (replace ensure pattern) 2026-03-19 16:14:48 -04:00
5 changed files with 166 additions and 153 deletions

View File

@@ -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 = (

View File

@@ -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),

View File

@@ -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)

View File

@@ -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<string, Info>
dirs: Set<string>
task?: Promise<void>
}
type Cache = State & {
ensure: () => Promise<void>
}
export interface Interface {
@@ -68,153 +62,172 @@ export namespace Skill {
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)
export const layer: Layer.Layer<Service, never, InstanceContext | Discovery.Service | AppFileSystem.Service> =
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<string>(),
}
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)
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)
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),
Layer.provide(AppFileSystem.defaultLayer),
)
export async function get(name: string) {

View File

@@ -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"
}