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