Skip to content

Commit 99d392a

Browse files
authored
refactor: collapse skill barrel into skill/index.ts (#22912)
1 parent ae9a696 commit 99d392a

2 files changed

Lines changed: 264 additions & 263 deletions

File tree

Lines changed: 264 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,264 @@
1-
export * as Skill from "./skill"
1+
import os from "os"
2+
import path from "path"
3+
import { pathToFileURL } from "url"
4+
import z from "zod"
5+
import { Effect, Layer, Context } from "effect"
6+
import { NamedError } from "@opencode-ai/shared/util/error"
7+
import type { Agent } from "@/agent/agent"
8+
import { Bus } from "@/bus"
9+
import { InstanceState } from "@/effect"
10+
import { Flag } from "@/flag/flag"
11+
import { Global } from "@/global"
12+
import { Permission } from "@/permission"
13+
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
14+
import { Config } from "../config"
15+
import { ConfigMarkdown } from "../config"
16+
import { Glob } from "@opencode-ai/shared/util/glob"
17+
import { Log } from "../util"
18+
import { Discovery } from "./discovery"
19+
20+
const log = Log.create({ service: "skill" })
21+
const EXTERNAL_DIRS = [".claude", ".agents"]
22+
const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
23+
const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
24+
const SKILL_PATTERN = "**/SKILL.md"
25+
26+
export const Info = z.object({
27+
name: z.string(),
28+
description: z.string(),
29+
location: z.string(),
30+
content: z.string(),
31+
})
32+
export type Info = z.infer<typeof Info>
33+
34+
export const InvalidError = NamedError.create(
35+
"SkillInvalidError",
36+
z.object({
37+
path: z.string(),
38+
message: z.string().optional(),
39+
issues: z.custom<z.core.$ZodIssue[]>().optional(),
40+
}),
41+
)
42+
43+
export const NameMismatchError = NamedError.create(
44+
"SkillNameMismatchError",
45+
z.object({
46+
path: z.string(),
47+
expected: z.string(),
48+
actual: z.string(),
49+
}),
50+
)
51+
52+
type State = {
53+
skills: Record<string, Info>
54+
dirs: Set<string>
55+
}
56+
57+
export interface Interface {
58+
readonly get: (name: string) => Effect.Effect<Info | undefined>
59+
readonly all: () => Effect.Effect<Info[]>
60+
readonly dirs: () => Effect.Effect<string[]>
61+
readonly available: (agent?: Agent.Info) => Effect.Effect<Info[]>
62+
}
63+
64+
const add = Effect.fnUntraced(function* (state: State, match: string, bus: Bus.Interface) {
65+
const md = yield* Effect.tryPromise({
66+
try: () => ConfigMarkdown.parse(match),
67+
catch: (err) => err,
68+
}).pipe(
69+
Effect.catch(
70+
Effect.fnUntraced(function* (err) {
71+
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
72+
? err.data.message
73+
: `Failed to parse skill ${match}`
74+
const { Session } = yield* Effect.promise(() => import("@/session"))
75+
yield* bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
76+
log.error("failed to load skill", { skill: match, err })
77+
return undefined
78+
}),
79+
),
80+
)
81+
82+
if (!md) return
83+
84+
const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
85+
if (!parsed.success) return
86+
87+
if (state.skills[parsed.data.name]) {
88+
log.warn("duplicate skill name", {
89+
name: parsed.data.name,
90+
existing: state.skills[parsed.data.name].location,
91+
duplicate: match,
92+
})
93+
}
94+
95+
state.dirs.add(path.dirname(match))
96+
state.skills[parsed.data.name] = {
97+
name: parsed.data.name,
98+
description: parsed.data.description,
99+
location: match,
100+
content: md.content,
101+
}
102+
})
103+
104+
const scan = Effect.fnUntraced(function* (
105+
state: State,
106+
bus: Bus.Interface,
107+
root: string,
108+
pattern: string,
109+
opts?: { dot?: boolean; scope?: string },
110+
) {
111+
const matches = yield* Effect.tryPromise({
112+
try: () =>
113+
Glob.scan(pattern, {
114+
cwd: root,
115+
absolute: true,
116+
include: "file",
117+
symlink: true,
118+
dot: opts?.dot,
119+
}),
120+
catch: (error) => error,
121+
}).pipe(
122+
Effect.catch((error) => {
123+
if (!opts?.scope) return Effect.die(error)
124+
log.error(`failed to scan ${opts.scope} skills`, { dir: root, error })
125+
return Effect.succeed([] as string[])
126+
}),
127+
)
128+
129+
yield* Effect.forEach(matches, (match) => add(state, match, bus), {
130+
concurrency: "unbounded",
131+
discard: true,
132+
})
133+
})
134+
135+
const loadSkills = Effect.fnUntraced(function* (
136+
state: State,
137+
config: Config.Interface,
138+
discovery: Discovery.Interface,
139+
bus: Bus.Interface,
140+
fsys: AppFileSystem.Interface,
141+
directory: string,
142+
worktree: string,
143+
) {
144+
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
145+
for (const dir of EXTERNAL_DIRS) {
146+
const root = path.join(Global.Path.home, dir)
147+
if (!(yield* fsys.isDir(root))) continue
148+
yield* scan(state, bus, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
149+
}
150+
151+
const upDirs = yield* fsys
152+
.up({ targets: EXTERNAL_DIRS, start: directory, stop: worktree })
153+
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
154+
155+
for (const root of upDirs) {
156+
yield* scan(state, bus, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
157+
}
158+
}
159+
160+
const configDirs = yield* config.directories()
161+
for (const dir of configDirs) {
162+
yield* scan(state, bus, dir, OPENCODE_SKILL_PATTERN)
163+
}
164+
165+
const cfg = yield* config.get()
166+
for (const item of cfg.skills?.paths ?? []) {
167+
const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
168+
const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded)
169+
if (!(yield* fsys.isDir(dir))) {
170+
log.warn("skill path not found", { path: dir })
171+
continue
172+
}
173+
174+
yield* scan(state, bus, dir, SKILL_PATTERN)
175+
}
176+
177+
for (const url of cfg.skills?.urls ?? []) {
178+
const pulledDirs = yield* discovery.pull(url)
179+
for (const dir of pulledDirs) {
180+
state.dirs.add(dir)
181+
yield* scan(state, bus, dir, SKILL_PATTERN)
182+
}
183+
}
184+
185+
log.info("init", { count: Object.keys(state.skills).length })
186+
})
187+
188+
export class Service extends Context.Service<Service, Interface>()("@opencode/Skill") {}
189+
190+
export const layer = Layer.effect(
191+
Service,
192+
Effect.gen(function* () {
193+
const discovery = yield* Discovery.Service
194+
const config = yield* Config.Service
195+
const bus = yield* Bus.Service
196+
const fsys = yield* AppFileSystem.Service
197+
const state = yield* InstanceState.make(
198+
Effect.fn("Skill.state")(function* (ctx) {
199+
const s: State = { skills: {}, dirs: new Set() }
200+
yield* loadSkills(s, config, discovery, bus, fsys, ctx.directory, ctx.worktree)
201+
return s
202+
}),
203+
)
204+
205+
const get = Effect.fn("Skill.get")(function* (name: string) {
206+
const s = yield* InstanceState.get(state)
207+
return s.skills[name]
208+
})
209+
210+
const all = Effect.fn("Skill.all")(function* () {
211+
const s = yield* InstanceState.get(state)
212+
return Object.values(s.skills)
213+
})
214+
215+
const dirs = Effect.fn("Skill.dirs")(function* () {
216+
const s = yield* InstanceState.get(state)
217+
return Array.from(s.dirs)
218+
})
219+
220+
const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
221+
const s = yield* InstanceState.get(state)
222+
const list = Object.values(s.skills).toSorted((a, b) => a.name.localeCompare(b.name))
223+
if (!agent) return list
224+
return list.filter((skill) => Permission.evaluate("skill", skill.name, agent.permission).action !== "deny")
225+
})
226+
227+
return Service.of({ get, all, dirs, available })
228+
}),
229+
)
230+
231+
export const defaultLayer = layer.pipe(
232+
Layer.provide(Discovery.defaultLayer),
233+
Layer.provide(Config.defaultLayer),
234+
Layer.provide(Bus.layer),
235+
Layer.provide(AppFileSystem.defaultLayer),
236+
)
237+
238+
export function fmt(list: Info[], opts: { verbose: boolean }) {
239+
if (list.length === 0) return "No skills are currently available."
240+
if (opts.verbose) {
241+
return [
242+
"<available_skills>",
243+
...list
244+
.sort((a, b) => a.name.localeCompare(b.name))
245+
.flatMap((skill) => [
246+
" <skill>",
247+
` <name>${skill.name}</name>`,
248+
` <description>${skill.description}</description>`,
249+
` <location>${pathToFileURL(skill.location).href}</location>`,
250+
" </skill>",
251+
]),
252+
"</available_skills>",
253+
].join("\n")
254+
}
255+
256+
return [
257+
"## Available Skills",
258+
...list
259+
.toSorted((a, b) => a.name.localeCompare(b.name))
260+
.map((skill) => `- **${skill.name}**: ${skill.description}`),
261+
].join("\n")
262+
}
263+
264+
export * as Skill from "."

0 commit comments

Comments
 (0)