Files
zmo-cli/packages/opencode/src/skill/skill.ts
Dax Raad c35bd39829 tui: parallelize skill downloads for faster loading
Refactored skill discovery to download skills in parallel instead of sequentially,

reducing load times when multiple skills need to be fetched from remote URLs.

Also updated AGENTS.md with guidance on using dev branch for diffs.
2026-02-06 00:36:11 -05:00

189 lines
5.5 KiB
TypeScript

import z from "zod"
import path from "path"
import os from "os"
import { Config } from "../config/config"
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 { Session } from "@/session"
import { Discovery } from "./discovery"
export namespace Skill {
const log = Log.create({ service: "skill" })
export const Info = z.object({
name: z.string(),
description: z.string(),
location: z.string(),
content: z.string(),
})
export type Info = z.infer<typeof Info>
export const InvalidError = NamedError.create(
"SkillInvalidError",
z.object({
path: z.string(),
message: z.string().optional(),
issues: z.custom<z.core.$ZodIssue[]>().optional(),
}),
)
export const NameMismatchError = NamedError.create(
"SkillNameMismatchError",
z.object({
path: z.string(),
expected: z.string(),
actual: z.string(),
}),
)
// External skill directories to search for (project-level and global)
// These follow the directory layout used by Claude Code and other agents.
const EXTERNAL_DIRS = [".claude", ".agents"]
const EXTERNAL_SKILL_GLOB = new Bun.Glob("skills/**/SKILL.md")
const OPENCODE_SKILL_GLOB = new Bun.Glob("{skill,skills}/**/SKILL.md")
const SKILL_GLOB = new Bun.Glob("**/SKILL.md")
export const state = Instance.state(async () => {
const skills: Record<string, Info> = {}
const dirs = new Set<string>()
const addSkill = async (match: string) => {
const md = await ConfigMarkdown.parse(match).catch((err) => {
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
? err.data.message
: `Failed to parse skill ${match}`
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
// 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,
})
}
dirs.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 Array.fromAsync(
EXTERNAL_SKILL_GLOB.scan({
cwd: root,
absolute: true,
onlyFiles: true,
followSymlinks: true,
dot: true,
}),
)
.then((matches) => Promise.all(matches.map(addSkill)))
.catch((error) => {
log.error(`failed to scan ${scope} skills`, { dir: root, error })
})
}
// 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.worktree,
})) {
await scanExternal(root, "project")
}
}
// Scan .opencode/skill/ directories
for (const dir of await Config.directories()) {
for await (const match of OPENCODE_SKILL_GLOB.scan({
cwd: dir,
absolute: true,
onlyFiles: true,
followSymlinks: true,
})) {
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
}
for await (const match of SKILL_GLOB.scan({
cwd: resolved,
absolute: true,
onlyFiles: true,
followSymlinks: true,
})) {
await addSkill(match)
}
}
// Download and load skills from URLs
for (const url of config.skills?.urls ?? []) {
const list = await Discovery.pull(url)
for (const dir of list) {
dirs.add(dir)
for await (const match of SKILL_GLOB.scan({
cwd: dir,
absolute: true,
onlyFiles: true,
followSymlinks: true,
})) {
await addSkill(match)
}
}
}
return {
skills,
dirs: Array.from(dirs),
}
})
export async function get(name: string) {
return state().then((x) => x.skills[name])
}
export async function all() {
return state().then((x) => Object.values(x.skills))
}
export async function dirs() {
return state().then((x) => x.dirs)
}
}