11#!/usr/bin/env bun
2- import { mkdir } from "node:fs/promises"
2+ import { mkdir , mkdtemp , readdir , cp , rm , rename } from "node:fs/promises"
33import path from "node:path"
4+ import os from "node:os"
45import YAML from "yaml"
56
67type Step =
78 | { type : "download" ; url : string ; dest : string }
89 | { type : "git-export" ; url : string }
10+ | { type : "git-export" ; repo : string ; path : string ; version ?: string }
911 | { type : "uv-pip" ; packages : string [ ] }
1012
13+ type GitExportSpec = Extract < Step , { type : "git-export" } >
14+ type ResolvedGitExport = {
15+ repo : string
16+ ref : string | null
17+ path : string
18+ }
19+ type Workspace = {
20+ dir : string
21+ cloneDir : string
22+ ref : string | null
23+ }
24+
1125type Skill = {
1226 name : string
1327 steps : Step [ ]
@@ -22,11 +36,16 @@ const root = path.join(cfg, "skills")
2236const base = process . env . OPENCODE_BUILD_DIR || "/tmp"
2337const manifestPath = process . env . SKILLS_MANIFEST || path . join ( base , "skills.yaml" )
2438const gitExport = process . env . GIT_EXPORT_SCRIPT || path . join ( base , "scripts" , "git-export.ts" )
39+ const workspaces = new Map < string , Workspace > ( )
2540
2641function fail ( msg : string ) : never {
2742 throw new Error ( `[install-skills] ${ msg } ` )
2843}
2944
45+ function info ( msg : string ) {
46+ console . log ( `[install-skills] ${ msg } ` )
47+ }
48+
3049function safe ( base : string , dest : string ) {
3150 const file = path . resolve ( base , dest )
3251 const rel = path . relative ( base , file )
@@ -38,6 +57,11 @@ async function ensure(dir: string) {
3857 await mkdir ( dir , { recursive : true } )
3958}
4059
60+ async function resetDir ( dir : string ) {
61+ await rm ( dir , { recursive : true , force : true } )
62+ await mkdir ( dir , { recursive : true } )
63+ }
64+
4165async function shell ( args : string [ ] ) {
4266 const proc = Bun . spawn ( args , {
4367 stdout : "inherit" ,
@@ -48,6 +72,98 @@ async function shell(args: string[]) {
4872 if ( code !== 0 ) fail ( `command failed (${ code } ): ${ args . join ( " " ) } ` )
4973}
5074
75+ function isGitHubRepo ( repo : string ) {
76+ return / ^ [ A - Z a - z 0 - 9 _ . - ] + \/ [ A - Z a - z 0 - 9 _ . - ] + $ / . test ( repo )
77+ }
78+
79+ async function resolveLatestReleaseTag ( repo : string ) {
80+ const res = await fetch ( `https://api.github.com/repos/${ repo } /releases/latest` , {
81+ headers : {
82+ Accept : "application/vnd.github+json" ,
83+ "User-Agent" : "install-skills" ,
84+ } ,
85+ } )
86+
87+ if ( res . status === 404 ) return null
88+ if ( ! res . ok ) fail ( `git-export: failed latest release lookup for ${ repo } (${ res . status } )` )
89+
90+ const data = ( await res . json ( ) ) as { tag_name ?: unknown }
91+ const tag = typeof data . tag_name === "string" && data . tag_name ? data . tag_name : null
92+ if ( tag ) info ( `git-export: resolved latest release for ${ repo } -> ${ tag } ` )
93+ return tag
94+ }
95+
96+ async function resolveRef ( step : GitExportSpec ) : Promise < ResolvedGitExport > {
97+ if ( ! step . repo ) fail ( `git-export: missing repo` )
98+ if ( ! step . path ) fail ( `git-export: missing path for ${ step . repo } ` )
99+ if ( ! isGitHubRepo ( step . repo ) ) fail ( `git-export: unsupported repo format: ${ step . repo } ` )
100+
101+ const version = step . version ?? "latest"
102+ const ref = version === "latest" ? await resolveLatestReleaseTag ( step . repo ) : version
103+ return { repo : step . repo , path : step . path , ref }
104+ }
105+
106+ function workspaceKey ( repo : string , ref : string | null ) {
107+ return `${ repo } @${ ref ?? "HEAD" } `
108+ }
109+
110+ async function ensureWorkspace ( repo : string , ref : string | null ) {
111+ const key = workspaceKey ( repo , ref )
112+ const existing = workspaces . get ( key )
113+ if ( existing ) return existing
114+
115+ const dir = await mkdtemp ( path . join ( os . tmpdir ( ) , "skills-export-" ) )
116+ const cloneDir = path . join ( dir , "repo" )
117+ const repoUrl = `https://github.com/${ repo } .git`
118+ await shell ( [ "git" , "clone" , "--depth" , "1" , "--filter=tree:0" , "--no-checkout" , repoUrl , cloneDir ] )
119+ if ( ref ) {
120+ await shell ( [ "git" , "-C" , cloneDir , "fetch" , "--depth" , "1" , "origin" , ref ] )
121+ await shell ( [ "git" , "-C" , cloneDir , "checkout" , "--detach" , "FETCH_HEAD" ] )
122+ } else {
123+ await shell ( [ "git" , "-C" , cloneDir , "checkout" ] )
124+ }
125+
126+ const workspace : Workspace = { dir, cloneDir, ref }
127+ workspaces . set ( key , workspace )
128+ return workspace
129+ }
130+
131+ async function copySubtreeFromWorkspace ( workspace : Workspace , sourcePath : string , destDir : string ) {
132+ const sourceDir = sourcePath ? path . join ( workspace . cloneDir , sourcePath ) : workspace . cloneDir
133+ const entries = await readdir ( sourceDir )
134+ await ensure ( destDir )
135+ for ( const entry of entries ) {
136+ if ( entry === ".git" ) continue
137+ await cp ( path . join ( sourceDir , entry ) , path . join ( destDir , entry ) , { recursive : true , force : true , verbatimSymlinks : true } )
138+ }
139+ }
140+
141+ async function cleanupWorkspaces ( ) {
142+ for ( const workspace of workspaces . values ( ) ) {
143+ await rm ( workspace . dir , { recursive : true , force : true } )
144+ }
145+ workspaces . clear ( )
146+ }
147+
148+ async function runSkill ( name : string , steps : Step [ ] ) {
149+ const destDir = path . join ( root , name )
150+ const stageParent = await mkdtemp ( path . join ( os . tmpdir ( ) , "skills-install-" ) )
151+ const stageDir = path . join ( stageParent , name )
152+
153+ try {
154+ await resetDir ( stageDir )
155+ for ( const step of steps ) {
156+ await run ( name , step , stageDir )
157+ }
158+
159+ await rm ( destDir , { recursive : true , force : true } )
160+ await ensure ( path . dirname ( destDir ) )
161+ await rename ( stageDir , destDir )
162+ } finally {
163+ await rm ( stageParent , { recursive : true , force : true } )
164+ }
165+ }
166+
51167async function run ( name : string , step : Step , dir : string ) {
52168 if ( step . type === "download" ) {
53169 await ensure ( dir )
@@ -59,7 +175,14 @@ async function run(name: string, step: Step, dir: string) {
59175
60176 if ( step . type === "git-export" ) {
61177 await ensure ( dir )
62- await shell ( [ "bun" , gitExport , step . url , dir , "--force" ] )
178+ if ( "url" in step ) {
179+ await shell ( [ "bun" , gitExport , step . url , dir , "--force" ] )
180+ return
181+ }
182+
183+ const resolved = await resolveRef ( step )
184+ const workspace = await ensureWorkspace ( resolved . repo , resolved . ref )
185+ await copySubtreeFromWorkspace ( workspace , resolved . path , dir )
63186 return
64187 }
65188
@@ -74,22 +197,23 @@ async function run(name: string, step: Step, dir: string) {
74197}
75198
76199function parse ( input : string ) : Manifest {
77- const parsed = YAML . parse ( input ) as Manifest
200+ const parsed = YAML . parse ( input ) as Partial < Manifest > | null
78201 if ( ! parsed || ! Array . isArray ( parsed . skills ) ) fail ( `invalid manifest at ${ manifestPath } ` )
79- return parsed
202+ return { skills : parsed . skills as Skill [ ] }
80203}
81204
82205async function main ( ) {
83206 const manifest = parse ( await Bun . file ( manifestPath ) . text ( ) )
84207 await ensure ( root )
85208
86- for ( const skill of manifest . skills ) {
87- if ( ! skill . name ) fail ( `skill missing name` )
88- if ( ! Array . isArray ( skill . steps ) ) fail ( `${ skill . name } : missing steps` )
89- const dir = path . join ( root , skill . name )
90- for ( const step of skill . steps ) {
91- await run ( skill . name , step , dir )
209+ try {
210+ for ( const skill of manifest . skills ) {
211+ if ( ! skill . name ) fail ( `skill missing name` )
212+ if ( ! Array . isArray ( skill . steps ) ) fail ( `${ skill . name } : missing steps` )
213+ await runSkill ( skill . name , skill . steps )
92214 }
215+ } finally {
216+ await cleanupWorkspaces ( )
93217 }
94218}
95219
0 commit comments