11#!/usr/bin/env bun
2- import { mkdir , mkdtemp , readdir , cp , rm } from "node:fs/promises"
2+ import { mkdir , mkdtemp , readdir , cp , rm , rename } from "node:fs/promises"
33import path from "node:path"
44import os from "node:os"
55import YAML from "yaml"
66
77type Step =
88 | { type : "download" ; url : string ; dest : string }
99 | { type : "git-export" ; url : string }
10- | { type : "git-export" ; repo : string ; path : string ; ref_env ?: string }
10+ | { type : "git-export" ; repo : string ; path : string ; version ?: string }
1111 | { type : "uv-pip" ; packages : string [ ] }
1212
1313type GitExportSpec = Extract < Step , { type : "git-export" } >
1414type ResolvedGitExport = {
1515 repo : string
16- ref : string
16+ ref : string | null
1717 path : string
1818}
1919type Workspace = {
2020 dir : string
2121 cloneDir : string
22- ref : string
22+ ref : string | null
2323}
2424
2525type Skill = {
@@ -42,6 +42,10 @@ function fail(msg: string): never {
4242 throw new Error ( `[install-skills] ${ msg } ` )
4343}
4444
45+ function info ( msg : string ) {
46+ console . log ( `[install-skills] ${ msg } ` )
47+ }
48+
4549function safe ( base : string , dest : string ) {
4650 const file = path . resolve ( base , dest )
4751 const rel = path . relative ( base , file )
@@ -53,6 +57,11 @@ async function ensure(dir: string) {
5357 await mkdir ( dir , { recursive : true } )
5458}
5559
60+ async function resetDir ( dir : string ) {
61+ await rm ( dir , { recursive : true , force : true } )
62+ await mkdir ( dir , { recursive : true } )
63+ }
64+
5665async function shell ( args : string [ ] ) {
5766 const proc = Bun . spawn ( args , {
5867 stdout : "inherit" ,
@@ -67,22 +76,38 @@ function isGitHubRepo(repo: string) {
6776 return / ^ [ A - Z a - z 0 - 9 _ . - ] + \/ [ A - Z a - z 0 - 9 _ . - ] + $ / . test ( repo )
6877}
6978
70- function resolveRef ( step : GitExportSpec ) : ResolvedGitExport {
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 > {
7197 if ( ! step . repo ) fail ( `git-export: missing repo` )
7298 if ( ! step . path ) fail ( `git-export: missing path for ${ step . repo } ` )
73- if ( ! step . ref_env ) fail ( `git-export: missing ref_env for ${ step . repo } /${ step . path } ` )
74-
75- const ref = process . env [ step . ref_env ]
76- if ( ! ref ) fail ( `git-export: missing environment variable ${ step . ref_env } ` )
7799 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
78103 return { repo : step . repo , path : step . path , ref }
79104}
80105
81- function workspaceKey ( repo : string , ref : string ) {
82- return `${ repo } @${ ref } `
106+ function workspaceKey ( repo : string , ref : string | null ) {
107+ return `${ repo } @${ ref ?? "HEAD" } `
83108}
84109
85- async function ensureWorkspace ( repo : string , ref : string ) {
110+ async function ensureWorkspace ( repo : string , ref : string | null ) {
86111 const key = workspaceKey ( repo , ref )
87112 const existing = workspaces . get ( key )
88113 if ( existing ) return existing
@@ -91,8 +116,12 @@ async function ensureWorkspace(repo: string, ref: string) {
91116 const cloneDir = path . join ( dir , "repo" )
92117 const repoUrl = `https://github.com/${ repo } .git`
93118 await shell ( [ "git" , "clone" , "--depth" , "1" , "--filter=tree:0" , "--no-checkout" , repoUrl , cloneDir ] )
94- await shell ( [ "git" , "-C" , cloneDir , "fetch" , "--depth" , "1" , "origin" , ref ] )
95- await shell ( [ "git" , "-C" , cloneDir , "checkout" , "--detach" , "FETCH_HEAD" ] )
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+ }
96125
97126 const workspace : Workspace = { dir, cloneDir, ref }
98127 workspaces . set ( key , workspace )
@@ -116,6 +145,25 @@ async function cleanupWorkspaces() {
116145 workspaces . clear ( )
117146}
118147
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+
119167async function run ( name : string , step : Step , dir : string ) {
120168 if ( step . type === "download" ) {
121169 await ensure ( dir )
@@ -132,7 +180,7 @@ async function run(name: string, step: Step, dir: string) {
132180 return
133181 }
134182
135- const resolved = resolveRef ( step )
183+ const resolved = await resolveRef ( step )
136184 const workspace = await ensureWorkspace ( resolved . repo , resolved . ref )
137185 await copySubtreeFromWorkspace ( workspace , resolved . path , dir )
138186 return
@@ -162,10 +210,7 @@ async function main() {
162210 for ( const skill of manifest . skills ) {
163211 if ( ! skill . name ) fail ( `skill missing name` )
164212 if ( ! Array . isArray ( skill . steps ) ) fail ( `${ skill . name } : missing steps` )
165- const dir = path . join ( root , skill . name )
166- for ( const step of skill . steps ) {
167- await run ( skill . name , step , dir )
168- }
213+ await runSkill ( skill . name , skill . steps )
169214 }
170215 } finally {
171216 await cleanupWorkspaces ( )
0 commit comments