1- import path from "path"
21import z from "zod"
32import { Config } from "../config/config"
4- import { Filesystem } from "../util/filesystem"
53import { Instance } from "../project/instance"
64import { NamedError } from "@opencode-ai/util/error"
75import { ConfigMarkdown } from "../config/markdown"
8- import { Log } from "../util/log"
96
107export namespace Skill {
11- const log = Log . create ( { service : "skill" } )
12-
13- // Name: 1-64 chars, lowercase alphanumeric and hyphens, no consecutive hyphens, can't start/end with hyphen
14- const NAME_REGEX = / ^ [ a - z 0 - 9 ] + ( - [ a - z 0 - 9 ] + ) * $ /
15-
16- export const Frontmatter = z . object ( {
17- name : z
18- . string ( )
19- . min ( 1 )
20- . max ( 64 )
21- . refine ( ( val ) => NAME_REGEX . test ( val ) , {
22- message :
23- "Name must be lowercase alphanumeric with hyphens, no consecutive hyphens, cannot start or end with hyphen" ,
24- } ) ,
25- description : z . string ( ) . min ( 1 ) . max ( 1024 ) ,
26- license : z . string ( ) . optional ( ) ,
27- compatibility : z . string ( ) . max ( 500 ) . optional ( ) ,
28- metadata : z . record ( z . string ( ) , z . string ( ) ) . optional ( ) ,
8+ export const Info = z . object ( {
9+ name : z . string ( ) ,
10+ description : z . string ( ) ,
11+ location : z . string ( ) ,
2912 } )
30-
31- export type Frontmatter = z . infer < typeof Frontmatter >
32-
33- export interface Info {
34- name : string
35- description : string
36- location : string
37- license ?: string
38- compatibility ?: string
39- metadata ?: Record < string , string >
40- }
13+ export type Info = z . infer < typeof Info >
4114
4215 export const InvalidError = NamedError . create (
4316 "SkillInvalidError" ,
@@ -57,98 +30,42 @@ export namespace Skill {
5730 } ) ,
5831 )
5932
60- const SKILL_GLOB = new Bun . Glob ( "skill/*/SKILL.md" )
61- // const CLAUDE_SKILL_GLOB = new Bun.Glob("*/SKILL.md")
33+ const SKILL_GLOB = new Bun . Glob ( "skill/**/SKILL.md" )
6234
63- async function discover ( ) : Promise < string [ ] > {
35+ export const state = Instance . state ( async ( ) = > {
6436 const directories = await Config . directories ( )
37+ const skills : Record < string , Info > = { }
6538
66- const paths : string [ ] = [ ]
67-
68- // Scan skill/ subdirectory in config directories (.opencode/, ~/.opencode/, etc.)
6939 for ( const dir of directories ) {
7040 for await ( const match of SKILL_GLOB . scan ( {
7141 cwd : dir ,
7242 absolute : true ,
7343 onlyFiles : true ,
7444 followSymlinks : true ,
7545 } ) ) {
76- paths . push ( match )
46+ const md = await ConfigMarkdown . parse ( match )
47+ if ( ! md ) {
48+ continue
49+ }
50+
51+ const parsed = Info . pick ( { name : true , description : true } ) . safeParse ( md . data )
52+ if ( ! parsed . success ) continue
53+ skills [ parsed . data . name ] = {
54+ name : parsed . data . name ,
55+ description : parsed . data . description ,
56+ location : match ,
57+ }
7758 }
7859 }
7960
80- // Also scan .claude/skills/ walking up from cwd to worktree
81- // for await (const dir of Filesystem.up({
82- // targets: [".claude/skills"],
83- // start: Instance.directory,
84- // stop: Instance.worktree,
85- // })) {
86- // for await (const match of CLAUDE_SKILL_GLOB.scan({
87- // cwd: dir,
88- // absolute: true,
89- // onlyFiles: true,
90- // followSymlinks: true,
91- // })) {
92- // paths.push(match)
93- // }
94- // }
95-
96- return paths
97- }
98-
99- async function load ( skillMdPath : string ) : Promise < Info > {
100- const md = await ConfigMarkdown . parse ( skillMdPath )
101- if ( ! md . data ) {
102- throw new InvalidError ( {
103- path : skillMdPath ,
104- message : "SKILL.md must have YAML frontmatter" ,
105- } )
106- }
107-
108- const parsed = Frontmatter . safeParse ( md . data )
109- if ( ! parsed . success ) {
110- throw new InvalidError ( {
111- path : skillMdPath ,
112- issues : parsed . error . issues ,
113- } )
114- }
115-
116- const frontmatter = parsed . data
117- const skillDir = path . dirname ( skillMdPath )
118- const dirName = path . basename ( skillDir )
119-
120- if ( frontmatter . name !== dirName ) {
121- throw new NameMismatchError ( {
122- path : skillMdPath ,
123- expected : dirName ,
124- actual : frontmatter . name ,
125- } )
126- }
127-
128- return {
129- name : frontmatter . name ,
130- description : frontmatter . description ,
131- location : skillMdPath ,
132- license : frontmatter . license ,
133- compatibility : frontmatter . compatibility ,
134- metadata : frontmatter . metadata ,
135- }
136- }
137-
138- export const state = Instance . state ( async ( ) => {
139- const paths = await discover ( )
140- const skills : Info [ ] = [ ]
141-
142- for ( const skillPath of paths ) {
143- const info = await load ( skillPath )
144- log . info ( "loaded skill" , { name : info . name , location : info . location } )
145- skills . push ( info )
146- }
147-
14861 return skills
14962 } )
15063
151- export async function all ( ) : Promise < Info [ ] > {
152- return state ( )
64+ export async function get ( name : string ) {
65+ return state ( ) . then ( ( x ) => x [ name ] )
66+ }
67+
68+ export async function all ( ) {
69+ return state ( ) . then ( ( x ) => Object . values ( x ) )
15370 }
15471}
0 commit comments