mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-17 12:56:41 +02:00
319 lines
10 KiB
TypeScript
319 lines
10 KiB
TypeScript
import path from "path"
|
|
import { pathToFileURL } from "url"
|
|
import z from "zod"
|
|
import { Effect, Layer, Context, Schema } from "effect"
|
|
import { zod } from "@opencode-ai/core/effect-zod"
|
|
import { withStatics } from "@opencode-ai/core/schema"
|
|
import { NamedError } from "@opencode-ai/core/util/error"
|
|
import type { Agent } from "@/agent/agent"
|
|
import { Bus } from "@/bus"
|
|
import { InstanceState } from "@/effect/instance-state"
|
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
|
import { Global } from "@opencode-ai/core/global"
|
|
import { Permission } from "@/permission"
|
|
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
|
import { Config } from "@/config/config"
|
|
import { ConfigMarkdown } from "@/config/markdown"
|
|
import { Glob } from "@opencode-ai/core/util/glob"
|
|
import * as Log from "@opencode-ai/core/util/log"
|
|
import { Discovery } from "./discovery"
|
|
import CUSTOMIZE_OPENCODE_SKILL_BODY from "./prompt/customize-opencode.md" with { type: "text" }
|
|
|
|
const log = Log.create({ service: "skill" })
|
|
const CLAUDE_EXTERNAL_DIR = ".claude"
|
|
const AGENTS_EXTERNAL_DIR = ".agents"
|
|
const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
|
|
const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
|
|
const SKILL_PATTERN = "**/SKILL.md"
|
|
|
|
// Built-in skill that ships with opencode. The model's intuition for what an
|
|
// opencode.json should look like is often wrong, and opencode hard-fails on
|
|
// invalid config, so users hit cryptic startup errors. Loading this skill
|
|
// when the model is asked to touch opencode's own config files gives it the
|
|
// actual schemas instead of guesses.
|
|
const CUSTOMIZE_OPENCODE_SKILL_NAME = "customize-opencode"
|
|
const CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION =
|
|
"Use ONLY when the user is editing or creating opencode's own configuration: opencode.json, opencode.jsonc, files under .opencode/, or files under ~/.config/opencode/. Also use when creating or fixing opencode agents, subagents, skills, plugins, MCP servers, or permission rules. Do not use for the user's own application code, or for any project that is not configuring opencode itself."
|
|
|
|
export const Info = Schema.Struct({
|
|
name: Schema.String,
|
|
description: Schema.optional(Schema.String),
|
|
location: Schema.String,
|
|
content: Schema.String,
|
|
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
|
export type Info = Schema.Schema.Type<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(),
|
|
}),
|
|
)
|
|
|
|
type State = {
|
|
skills: Record<string, Info>
|
|
dirs: Set<string>
|
|
}
|
|
|
|
type DiscoveryState = {
|
|
matches: string[]
|
|
dirs: string[]
|
|
}
|
|
|
|
type ScanState = {
|
|
matches: Set<string>
|
|
dirs: Set<string>
|
|
}
|
|
|
|
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 = Effect.fnUntraced(function* (state: State, match: string, bus: Bus.Interface) {
|
|
const md = yield* Effect.tryPromise({
|
|
try: () => ConfigMarkdown.parse(match),
|
|
catch: (err) => err,
|
|
}).pipe(
|
|
Effect.catch(
|
|
Effect.fnUntraced(function* (err) {
|
|
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
|
? err.data.message
|
|
: `Failed to parse skill ${match}`
|
|
const { Session } = yield* Effect.promise(() => import("@/session/session"))
|
|
yield* 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 = z.object({ name: z.string(), description: z.string().optional() }).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.fnUntraced(function* (
|
|
state: ScanState,
|
|
root: string,
|
|
pattern: string,
|
|
opts?: { dot?: boolean; scope?: string },
|
|
) {
|
|
const matches = yield* Effect.tryPromise({
|
|
try: () =>
|
|
Glob.scan(pattern, {
|
|
cwd: root,
|
|
absolute: true,
|
|
include: "file",
|
|
symlink: true,
|
|
dot: opts?.dot,
|
|
}),
|
|
catch: (error) => error,
|
|
}).pipe(
|
|
Effect.catch((error) => {
|
|
if (!opts?.scope) return Effect.die(error)
|
|
log.error(`failed to scan ${opts.scope} skills`, { dir: root, error })
|
|
return Effect.succeed([] as string[])
|
|
}),
|
|
)
|
|
|
|
for (const match of matches) {
|
|
state.matches.add(match)
|
|
state.dirs.add(path.dirname(match))
|
|
}
|
|
})
|
|
|
|
const discoverSkills = Effect.fnUntraced(function* (
|
|
config: Config.Interface,
|
|
discovery: Discovery.Interface,
|
|
fsys: AppFileSystem.Interface,
|
|
global: Global.Interface,
|
|
directory: string,
|
|
worktree: string,
|
|
) {
|
|
const state: ScanState = { matches: new Set(), dirs: new Set() }
|
|
|
|
const externalDirs: string[] = []
|
|
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
|
|
if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_SKILLS) externalDirs.push(CLAUDE_EXTERNAL_DIR)
|
|
externalDirs.push(AGENTS_EXTERNAL_DIR)
|
|
|
|
for (const dir of externalDirs) {
|
|
const root = path.join(global.home, dir)
|
|
if (!(yield* fsys.isDir(root))) continue
|
|
yield* scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
|
|
}
|
|
|
|
const upDirs = yield* fsys
|
|
.up({ targets: externalDirs, start: directory, stop: worktree })
|
|
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
|
|
|
for (const root of upDirs) {
|
|
yield* scan(state, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
|
|
}
|
|
}
|
|
|
|
const configDirs = yield* config.directories()
|
|
for (const dir of configDirs) {
|
|
yield* scan(state, dir, OPENCODE_SKILL_PATTERN)
|
|
}
|
|
|
|
const cfg = yield* config.get()
|
|
for (const item of cfg.skills?.paths ?? []) {
|
|
const expanded = item.startsWith("~/") ? path.join(global.home, item.slice(2)) : item
|
|
const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded)
|
|
if (!(yield* fsys.isDir(dir))) {
|
|
log.warn("skill path not found", { path: dir })
|
|
continue
|
|
}
|
|
|
|
yield* scan(state, dir, SKILL_PATTERN)
|
|
}
|
|
|
|
for (const url of cfg.skills?.urls ?? []) {
|
|
const pulledDirs = yield* discovery.pull(url)
|
|
for (const dir of pulledDirs) {
|
|
yield* scan(state, dir, SKILL_PATTERN)
|
|
}
|
|
}
|
|
|
|
return {
|
|
matches: Array.from(state.matches),
|
|
dirs: Array.from(state.dirs),
|
|
}
|
|
})
|
|
|
|
const loadSkills = Effect.fnUntraced(function* (state: State, discovered: DiscoveryState, bus: Bus.Interface) {
|
|
yield* Effect.forEach(discovered.matches, (match) => add(state, match, bus), {
|
|
concurrency: "unbounded",
|
|
discard: true,
|
|
})
|
|
|
|
log.info("init", { count: Object.keys(state.skills).length })
|
|
})
|
|
|
|
export class Service extends Context.Service<Service, Interface>()("@opencode/Skill") {}
|
|
|
|
export const layer = Layer.effect(
|
|
Service,
|
|
Effect.gen(function* () {
|
|
const discovery = yield* Discovery.Service
|
|
const config = yield* Config.Service
|
|
const bus = yield* Bus.Service
|
|
const fsys = yield* AppFileSystem.Service
|
|
const global = yield* Global.Service
|
|
const discovered = yield* InstanceState.make(
|
|
Effect.fn("Skill.discovery")(function* (ctx) {
|
|
return yield* discoverSkills(config, discovery, fsys, global, ctx.directory, ctx.worktree)
|
|
}),
|
|
)
|
|
const state = yield* InstanceState.make(
|
|
Effect.fn("Skill.state")(function* () {
|
|
const s: State = { skills: {}, dirs: new Set() }
|
|
// Register the built-in skill BEFORE disk discovery so a user-disk
|
|
// skill with the same name can override it.
|
|
if (Flag.OPENCODE_EXPERIMENTAL_CUSTOMIZE_SKILL) {
|
|
s.skills[CUSTOMIZE_OPENCODE_SKILL_NAME] = {
|
|
name: CUSTOMIZE_OPENCODE_SKILL_NAME,
|
|
description: CUSTOMIZE_OPENCODE_SKILL_DESCRIPTION,
|
|
location: "<built-in>",
|
|
content: CUSTOMIZE_OPENCODE_SKILL_BODY,
|
|
}
|
|
}
|
|
yield* loadSkills(s, yield* InstanceState.get(discovered), bus)
|
|
return s
|
|
}),
|
|
)
|
|
|
|
const get = Effect.fn("Skill.get")(function* (name: string) {
|
|
const s = yield* InstanceState.get(state)
|
|
return s.skills[name]
|
|
})
|
|
|
|
const all = Effect.fn("Skill.all")(function* () {
|
|
const s = yield* InstanceState.get(state)
|
|
return Object.values(s.skills)
|
|
})
|
|
|
|
const dirs = Effect.fn("Skill.dirs")(function* () {
|
|
return (yield* InstanceState.get(discovered)).dirs
|
|
})
|
|
|
|
const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
|
|
const s = yield* InstanceState.get(state)
|
|
const list = Object.values(s.skills).toSorted((a, b) => a.name.localeCompare(b.name))
|
|
if (!agent) return list
|
|
return list.filter((skill) => Permission.evaluate("skill", skill.name, agent.permission).action !== "deny")
|
|
})
|
|
|
|
return Service.of({ get, all, dirs, available })
|
|
}),
|
|
)
|
|
|
|
export const defaultLayer = layer.pipe(
|
|
Layer.provide(Discovery.defaultLayer),
|
|
Layer.provide(Config.defaultLayer),
|
|
Layer.provide(Bus.layer),
|
|
Layer.provide(AppFileSystem.defaultLayer),
|
|
Layer.provide(Global.layer),
|
|
)
|
|
|
|
export function fmt(list: Info[], opts: { verbose: boolean }) {
|
|
const described = list.filter((skill) => skill.description !== undefined)
|
|
if (described.length === 0) return "No skills are currently available."
|
|
if (opts.verbose) {
|
|
return [
|
|
"<available_skills>",
|
|
...described
|
|
.toSorted((a, b) => a.name.localeCompare(b.name))
|
|
.flatMap((skill) => [
|
|
" <skill>",
|
|
` <name>${skill.name}</name>`,
|
|
` <description>${skill.description}</description>`,
|
|
` <location>${pathToFileURL(skill.location).href}</location>`,
|
|
" </skill>",
|
|
]),
|
|
"</available_skills>",
|
|
].join("\n")
|
|
}
|
|
|
|
return [
|
|
"## Available Skills",
|
|
...described
|
|
.toSorted((a, b) => a.name.localeCompare(b.name))
|
|
.map((skill) => `- **${skill.name}**: ${skill.description}`),
|
|
].join("\n")
|
|
}
|
|
|
|
export * as Skill from "."
|