@@ -0,0 +1,586 @@
import fs from 'fs' ;
import os from 'os' ;
import path from 'path' ;
import { execFile } from 'child_process' ;
import { promisify } from 'util' ;
import yaml from 'yaml' ;
import { discoverSkills } from './opencodeConfig' ;
const execFileAsync = promisify ( execFile ) ;
const DEFAULT_TIMEOUT_MS = 60 _000 ;
const DEFAULT_MAX_BUFFER = 4 * 1024 * 1024 ;
const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/ ;
type SkillScope = 'user' | 'project' ;
export type SkillsCatalogSourceConfig = {
id : string ;
label : string ;
description? : string ;
source : string ;
defaultSubpath? : string ;
} ;
type CuratedSource = SkillsCatalogSourceConfig ;
type SkillFrontmatter = {
name? : unknown ;
description? : unknown ;
[ key : string ] : unknown ;
} ;
export type SkillsCatalogItem = {
repoSource : string ;
repoSubpath? : string ;
skillDir : string ;
skillName : string ;
frontmatterName? : string ;
description? : string ;
installable : boolean ;
warnings? : string [ ] ;
} ;
type SkillsCatalogItemWithBadge = SkillsCatalogItem & {
sourceId : string ;
installed : { isInstalled : boolean ; scope? : SkillScope } ;
} ;
type SkillsRepoError =
| { kind : 'authRequired' ; message : string ; sshOnly : boolean }
| { kind : 'invalidSource' ; message : string }
| { kind : 'gitUnavailable' ; message : string }
| { kind : 'networkError' ; message : string }
| { kind : 'unknown' ; message : string }
| { kind : 'conflicts' ; message : string ; conflicts : Array < { skillName : string ; scope : SkillScope } > } ;
type SkillsRepoScanResult =
| { ok : true ; items : SkillsCatalogItem [ ] }
| { ok : false ; error : SkillsRepoError } ;
type SkillsInstallResult =
| { ok : true ; installed : Array < { skillName : string ; scope : SkillScope } > ; skipped : Array < { skillName : string ; reason : string } > }
| { ok : false ; error : SkillsRepoError } ;
export const CURATED_SOURCES : CuratedSource [ ] = [
{
id : 'anthropic' ,
label : 'Anthropic' ,
description : "Anthropic’ s public skills repository" ,
source : 'anthropics/skills' ,
defaultSubpath : 'skills' ,
} ,
] ;
function validateSkillName ( skillName : string ) : boolean {
if ( skillName . length < 1 || skillName . length > 64 ) return false ;
return SKILL_NAME_PATTERN . test ( skillName ) ;
}
function looksLikeAuthError ( message : string ) : boolean {
return (
/permission denied/i . test ( message ) ||
/publickey/i . test ( message ) ||
/could not read from remote repository/i . test ( message ) ||
/authentication failed/i . test ( message )
) ;
}
async function runGit ( args : string [ ] , options ? : { cwd? : string ; timeoutMs? : number } ) {
try {
const { stdout , stderr } = await execFileAsync ( 'git' , args , {
cwd : options?.cwd ,
timeout : options?.timeoutMs ? ? DEFAULT_TIMEOUT_MS ,
maxBuffer : DEFAULT_MAX_BUFFER ,
env : {
. . . process . env ,
GIT_TERMINAL_PROMPT : '0' ,
} ,
} ) ;
return { ok : true as const , stdout : stdout || '' , stderr : stderr || '' } ;
} catch ( error ) {
const err = error as { stdout? : string ; stderr? : string ; message? : string } ;
return {
ok : false as const ,
stdout : typeof err . stdout === 'string' ? err . stdout : '' ,
stderr : typeof err . stderr === 'string' ? err . stderr : '' ,
message : typeof err . message === 'string' ? err . message : 'Git command failed' ,
} ;
}
}
async function assertGitAvailable() {
const result = await runGit ( [ '--version' ] , { timeoutMs : 5_000 } ) ;
if ( ! result . ok ) {
return { ok : false as const , error : { kind : 'gitUnavailable' as const , message : 'Git is not available in PATH' } } ;
}
return { ok : true as const } ;
}
function parseSkillRepoSource ( input : string , subpath? : string ) {
const raw = ( input || '' ) . trim ( ) ;
if ( ! raw ) {
return { ok : false as const , error : { kind : 'invalidSource' as const , message : 'Repository source is required' } } ;
}
const explicitSubpath = subpath ? . trim ( ) ? subpath . trim ( ) : null ;
const sshMatch = raw . match ( /^git@github\.com:([^/\s]+)\/([^\s#]+)$/i ) ;
if ( sshMatch ) {
const owner = sshMatch [ 1 ] ;
const repo = sshMatch [ 2 ] . replace ( /\.git$/i , '' ) ;
return {
ok : true as const ,
normalizedRepo : ` ${ owner } / ${ repo } ` ,
cloneUrlHttps : ` https://github.com/ ${ owner } / ${ repo } .git ` ,
cloneUrlSsh : ` git@github.com: ${ owner } / ${ repo } .git ` ,
effectiveSubpath : explicitSubpath ,
} ;
}
const httpsMatch = raw . match ( /^https?:\/\/github\.com\/([^/\s]+)\/([^\s#]+)$/i ) ;
if ( httpsMatch ) {
const owner = httpsMatch [ 1 ] ;
const repo = httpsMatch [ 2 ] . replace ( /\.git$/i , '' ) ;
return {
ok : true as const ,
normalizedRepo : ` ${ owner } / ${ repo } ` ,
cloneUrlHttps : ` https://github.com/ ${ owner } / ${ repo } .git ` ,
cloneUrlSsh : ` git@github.com: ${ owner } / ${ repo } .git ` ,
effectiveSubpath : explicitSubpath ,
} ;
}
const shorthandMatch = raw . match ( /^([^/\s]+)\/([^/\s]+)(?:\/(.+))?$/ ) ;
if ( shorthandMatch ) {
const owner = shorthandMatch [ 1 ] ;
const repo = shorthandMatch [ 2 ] . replace ( /\.git$/i , '' ) ;
const shorthandSubpath = shorthandMatch [ 3 ] ? . trim ( ) || null ;
return {
ok : true as const ,
normalizedRepo : ` ${ owner } / ${ repo } ` ,
cloneUrlHttps : ` https://github.com/ ${ owner } / ${ repo } .git ` ,
cloneUrlSsh : ` git@github.com: ${ owner } / ${ repo } .git ` ,
effectiveSubpath : explicitSubpath || shorthandSubpath ,
} ;
}
return { ok : false as const , error : { kind : 'invalidSource' as const , message : 'Unsupported repository source format' } } ;
}
function parseSkillMd ( content : string ) : { frontmatter : SkillFrontmatter ; warnings : string [ ] } {
const match = content . match ( /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/ ) ;
if ( ! match ) {
return {
frontmatter : { } ,
warnings : [ 'Invalid SKILL.md: missing YAML frontmatter delimiter' ] ,
} ;
}
try {
const parsed = yaml . parse ( match [ 1 ] ) ;
const frontmatter = parsed && typeof parsed === 'object' ? ( parsed as SkillFrontmatter ) : { } ;
return { frontmatter , warnings : [ ] } ;
} catch {
return {
frontmatter : { } ,
warnings : [ 'Invalid SKILL.md: failed to parse YAML frontmatter' ] ,
} ;
}
}
async function safeRm ( dir : string ) {
try {
await fs . promises . rm ( dir , { recursive : true , force : true } ) ;
} catch {
// ignore
}
}
async function cloneRepo ( cloneUrl : string , targetDir : string ) {
const preferred = [ 'clone' , '--depth' , '1' , '--filter=blob:none' , '--no-checkout' , cloneUrl , targetDir ] ;
const fallback = [ 'clone' , '--depth' , '1' , '--no-checkout' , cloneUrl , targetDir ] ;
const result = await runGit ( preferred , { timeoutMs : 60_000 } ) ;
if ( result . ok ) return { ok : true as const } ;
const fallbackResult = await runGit ( fallback , { timeoutMs : 60_000 } ) ;
if ( fallbackResult . ok ) return { ok : true as const } ;
const combined = ` ${ fallbackResult . stderr } \ n ${ fallbackResult . message } ` . trim ( ) ;
if ( looksLikeAuthError ( combined ) ) {
return {
ok : false as const ,
error : {
kind : 'authRequired' as const ,
message : 'Private repositories are not supported in VS Code yet. Use Desktop/Web.' ,
sshOnly : true ,
} ,
} ;
}
return { ok : false as const , error : { kind : 'networkError' as const , message : combined || 'Failed to clone repository' } } ;
}
export async function scanSkillsRepository ( options : { source : string ; subpath? : string ; defaultSubpath? : string } ) : Promise < SkillsRepoScanResult > {
const gitCheck = await assertGitAvailable ( ) ;
if ( ! gitCheck . ok ) {
return { ok : false as const , error : gitCheck.error } ;
}
const parsed = parseSkillRepoSource ( options . source , options . subpath ) ;
if ( ! parsed . ok ) {
return { ok : false as const , error : parsed.error } ;
}
const effectiveSubpath = parsed . effectiveSubpath || options . defaultSubpath || null ;
const tempBase = await fs . promises . mkdtemp ( path . join ( os . tmpdir ( ) , 'openchamber-vscode-skills-scan-' ) ) ;
try {
const cloned = await cloneRepo ( parsed . cloneUrlHttps , tempBase ) ;
if ( ! cloned . ok ) {
return { ok : false as const , error : cloned.error } ;
}
const toFsPath = ( posixPath : string ) = > path . join ( tempBase , . . . posixPath . split ( '/' ) . filter ( Boolean ) ) ;
const patterns = effectiveSubpath
? [ ` ${ effectiveSubpath } /SKILL.md ` , ` ${ effectiveSubpath } /**/SKILL.md ` ]
: [ 'SKILL.md' , '**/SKILL.md' ] ;
let skillMdPaths : string [ ] | null = null ;
const sparseInit = await runGit ( [ '-C' , tempBase , 'sparse-checkout' , 'init' , '--no-cone' ] , { timeoutMs : 15_000 } ) ;
if ( sparseInit . ok ) {
const sparseSet = await runGit ( [ '-C' , tempBase , 'sparse-checkout' , 'set' , . . . patterns ] , { timeoutMs : 30_000 } ) ;
if ( sparseSet . ok ) {
const checkout = await runGit ( [ '-C' , tempBase , 'checkout' , '--force' , 'HEAD' ] , { timeoutMs : 60_000 } ) ;
if ( checkout . ok ) {
const lsFiles = await runGit ( [ '-C' , tempBase , 'ls-files' ] , { timeoutMs : 15_000 } ) ;
if ( lsFiles . ok ) {
skillMdPaths = lsFiles . stdout
. split ( /\r?\n/ )
. map ( ( line ) = > line . trim ( ) )
. filter ( Boolean )
. filter ( ( p ) = > p . endsWith ( '/SKILL.md' ) || p === 'SKILL.md' ) ;
}
}
}
}
if ( ! Array . isArray ( skillMdPaths ) ) {
const listArgs = [ '-C' , tempBase , 'ls-tree' , '-r' , '--name-only' , 'HEAD' ] ;
if ( effectiveSubpath ) {
listArgs . push ( '--' , effectiveSubpath ) ;
}
const list = await runGit ( listArgs , { timeoutMs : 30_000 } ) ;
if ( ! list . ok ) {
return { ok : true as const , items : [ ] as SkillsCatalogItem [ ] } ;
}
skillMdPaths = list . stdout
. split ( /\r?\n/ )
. map ( ( l ) = > l . trim ( ) )
. filter ( Boolean )
. filter ( ( p ) = > p . endsWith ( '/SKILL.md' ) || p === 'SKILL.md' ) ;
}
const skillDirs = Array . from ( new Set ( skillMdPaths . filter ( ( p ) = > p !== 'SKILL.md' ) . map ( ( p ) = > path . posix . dirname ( p ) ) ) ) ;
const items : SkillsCatalogItem [ ] = [ ] ;
for ( const skillDir of skillDirs ) {
const skillName = path . posix . basename ( skillDir ) ;
const skillMdPath = path . posix . join ( skillDir , 'SKILL.md' ) ;
const warnings : string [ ] = [ ] ;
let content = '' ;
try {
content = await fs . promises . readFile ( toFsPath ( skillMdPath ) , 'utf8' ) ;
} catch {
const show = await runGit ( [ '-C' , tempBase , 'show' , ` HEAD: ${ skillMdPath } ` ] , { timeoutMs : 15_000 } ) ;
if ( ! show . ok ) {
warnings . push ( 'Failed to read SKILL.md' ) ;
} else {
content = show . stdout ;
}
}
const parsedMd = parseSkillMd ( content ) ;
warnings . push ( . . . parsedMd . warnings ) ;
const description = typeof parsedMd . frontmatter . description === 'string' ? parsedMd.frontmatter.description : undefined ;
const frontmatterName = typeof parsedMd . frontmatter . name === 'string' ? parsedMd.frontmatter.name : undefined ;
const installable = validateSkillName ( skillName ) ;
if ( ! installable ) {
warnings . push ( 'Skill directory name is not a valid OpenCode skill name' ) ;
}
items . push ( {
repoSource : options.source ,
repoSubpath : effectiveSubpath || undefined ,
skillDir ,
skillName ,
frontmatterName ,
description ,
installable ,
warnings : warnings.length ? warnings : undefined ,
} ) ;
}
items . sort ( ( a , b ) = > String ( a . skillName ) . localeCompare ( String ( b . skillName ) ) ) ;
return { ok : true as const , items } ;
} finally {
await safeRm ( tempBase ) ;
}
}
async function copyDirectoryNoSymlinks ( srcDir : string , dstDir : string ) {
const srcReal = await fs . promises . realpath ( srcDir ) ;
const ensureDir = async ( dirPath : string ) = > {
await fs . promises . mkdir ( dirPath , { recursive : true } ) ;
} ;
const walk = async ( currentSrc : string , currentDst : string ) = > {
const entries = await fs . promises . readdir ( currentSrc , { withFileTypes : true } ) ;
for ( const entry of entries ) {
const nextSrc = path . join ( currentSrc , entry . name ) ;
const nextDst = path . join ( currentDst , entry . name ) ;
const stat = await fs . promises . lstat ( nextSrc ) ;
if ( stat . isSymbolicLink ( ) ) {
throw new Error ( 'Symlinks are not supported in skills' ) ;
}
const nextRealParent = await fs . promises . realpath ( path . dirname ( nextSrc ) ) ;
if ( ! nextRealParent . startsWith ( srcReal ) ) {
throw new Error ( 'Invalid source path traversal detected' ) ;
}
if ( stat . isDirectory ( ) ) {
await ensureDir ( nextDst ) ;
await walk ( nextSrc , nextDst ) ;
continue ;
}
if ( stat . isFile ( ) ) {
await ensureDir ( path . dirname ( nextDst ) ) ;
await fs . promises . copyFile ( nextSrc , nextDst ) ;
try {
await fs . promises . chmod ( nextDst , stat . mode & 0 o777 ) ;
} catch {
// best-effort
}
}
}
} ;
await ensureDir ( dstDir ) ;
await walk ( srcDir , dstDir ) ;
}
function getUserSkillBaseDir() {
return path . join ( os . homedir ( ) , '.config' , 'opencode' , 'skill' ) ;
}
function toFsPath ( repoDir : string , repoRelPosixPath : string ) {
const parts = repoRelPosixPath . split ( '/' ) . filter ( Boolean ) ;
return path . join ( repoDir , . . . parts ) ;
}
export async function installSkillsFromRepository ( options : {
source : string ;
subpath? : string ;
scope : SkillScope ;
workingDirectory? : string ;
selections : Array < { skillDir : string } > ;
conflictPolicy ? : 'prompt' | 'skipAll' | 'overwriteAll' ;
conflictDecisions? : Record < string , 'skip' | 'overwrite' > ;
} ) : Promise < SkillsInstallResult > {
const gitCheck = await assertGitAvailable ( ) ;
if ( ! gitCheck . ok ) {
return { ok : false as const , error : gitCheck.error } ;
}
if ( options . scope === 'project' && ! options . workingDirectory ) {
return { ok : false as const , error : { kind : 'invalidSource' as const , message : 'Project installs require a directory parameter' } } ;
}
const parsed = parseSkillRepoSource ( options . source , options . subpath ) ;
if ( ! parsed . ok ) {
return { ok : false as const , error : parsed.error } ;
}
const requestedDirs = options . selections . map ( ( s ) = > String ( s . skillDir || '' ) . trim ( ) ) . filter ( Boolean ) ;
if ( requestedDirs . length === 0 ) {
return { ok : false as const , error : { kind : 'invalidSource' as const , message : 'No skills selected for installation' } } ;
}
const userSkillDir = getUserSkillBaseDir ( ) ;
const skillPlans = requestedDirs . map ( ( dir ) = > {
const skillName = path . posix . basename ( dir ) ;
return { skillDirPosix : dir , skillName , installable : validateSkillName ( skillName ) } ;
} ) ;
const conflicts : Array < { skillName : string ; scope : SkillScope } > = [ ] ;
for ( const plan of skillPlans ) {
if ( ! plan . installable ) continue ;
const targetDir = options . scope === 'user'
? path . join ( userSkillDir , plan . skillName )
: path . join ( options . workingDirectory as string , '.opencode' , 'skill' , plan . skillName ) ;
if ( fs . existsSync ( targetDir ) ) {
const decision = options . conflictDecisions ? . [ plan . skillName ] ;
const hasAutoPolicy = options . conflictPolicy === 'skipAll' || options . conflictPolicy === 'overwriteAll' ;
if ( ! decision && ! hasAutoPolicy ) {
conflicts . push ( { skillName : plan.skillName , scope : options.scope } ) ;
}
}
}
if ( conflicts . length > 0 ) {
return {
ok : false as const ,
error : { kind : 'conflicts' as const , message : 'Some skills already exist in the selected scope' , conflicts } ,
} ;
}
const tempBase = await fs . promises . mkdtemp ( path . join ( os . tmpdir ( ) , 'openchamber-vscode-skills-install-' ) ) ;
try {
const cloned = await cloneRepo ( parsed . cloneUrlHttps , tempBase ) ;
if ( ! cloned . ok ) {
return { ok : false as const , error : cloned.error } ;
}
await runGit ( [ '-C' , tempBase , 'sparse-checkout' , 'init' , '--cone' ] , { timeoutMs : 15_000 } ) ;
const setResult = await runGit ( [ '-C' , tempBase , 'sparse-checkout' , 'set' , . . . requestedDirs ] , { timeoutMs : 30_000 } ) ;
if ( ! setResult . ok ) {
return { ok : false as const , error : { kind : 'unknown' as const , message : setResult.stderr || setResult . message || 'Failed to configure sparse checkout' } } ;
}
const checkoutResult = await runGit ( [ '-C' , tempBase , 'checkout' , '--force' , 'HEAD' ] , { timeoutMs : 60_000 } ) ;
if ( ! checkoutResult . ok ) {
return { ok : false as const , error : { kind : 'unknown' as const , message : checkoutResult.stderr || checkoutResult . message || 'Failed to checkout repository' } } ;
}
const installed : Array < { skillName : string ; scope : SkillScope } > = [ ] ;
const skipped : Array < { skillName : string ; reason : string } > = [ ] ;
for ( const plan of skillPlans ) {
if ( ! plan . installable ) {
skipped . push ( { skillName : plan.skillName , reason : 'Invalid skill name (directory basename)' } ) ;
continue ;
}
const srcDir = toFsPath ( tempBase , plan . skillDirPosix ) ;
const skillMdPath = path . join ( srcDir , 'SKILL.md' ) ;
if ( ! fs . existsSync ( skillMdPath ) ) {
skipped . push ( { skillName : plan.skillName , reason : 'SKILL.md not found in selected directory' } ) ;
continue ;
}
const targetDir = options . scope === 'user'
? path . join ( userSkillDir , plan . skillName )
: path . join ( options . workingDirectory as string , '.opencode' , 'skill' , plan . skillName ) ;
const exists = fs . existsSync ( targetDir ) ;
let decision = options . conflictDecisions ? . [ plan . skillName ] || null ;
if ( ! decision ) {
if ( exists && options . conflictPolicy === 'skipAll' ) decision = 'skip' ;
if ( exists && options . conflictPolicy === 'overwriteAll' ) decision = 'overwrite' ;
if ( ! exists ) decision = 'overwrite' ;
}
if ( exists && decision === 'skip' ) {
skipped . push ( { skillName : plan.skillName , reason : 'Already installed (skipped)' } ) ;
continue ;
}
if ( exists && decision === 'overwrite' ) {
await safeRm ( targetDir ) ;
}
await fs . promises . mkdir ( path . dirname ( targetDir ) , { recursive : true } ) ;
try {
await copyDirectoryNoSymlinks ( srcDir , targetDir ) ;
installed . push ( { skillName : plan.skillName , scope : options.scope } ) ;
} catch ( error ) {
await safeRm ( targetDir ) ;
skipped . push ( {
skillName : plan.skillName ,
reason : error instanceof Error ? error . message : 'Failed to copy skill files' ,
} ) ;
}
}
return { ok : true as const , installed , skipped } ;
} finally {
await safeRm ( tempBase ) ;
}
}
const catalogCache = new Map < string , { expiresAt : number ; items : SkillsCatalogItem [ ] } > ( ) ;
const CATALOG_TTL_MS = 30 * 60 * 1000 ;
export async function getSkillsCatalog (
workingDirectory? : string ,
refresh? : boolean ,
additionalSources? : SkillsCatalogSourceConfig [ ]
) {
const sources = [ . . . CURATED_SOURCES , . . . ( Array . isArray ( additionalSources ) ? additionalSources : [ ] ) ] ;
const discovered = discoverSkills ( workingDirectory ) ;
const installedByName = new Map ( discovered . map ( ( s ) = > [ s . name , s ] ) ) ;
const itemsBySource : Record < string , SkillsCatalogItemWithBadge [ ] > = { } ;
for ( const src of sources ) {
const parsed = parseSkillRepoSource ( src . source ) ;
if ( ! parsed . ok ) {
itemsBySource [ src . id ] = [ ] ;
continue ;
}
const effectiveSubpath = src . defaultSubpath || parsed . effectiveSubpath || '' ;
const cacheKey = ` ${ parsed . normalizedRepo } :: ${ effectiveSubpath } ` ;
let cached = ! refresh ? catalogCache . get ( cacheKey ) : null ;
if ( cached && Date . now ( ) >= cached . expiresAt ) {
catalogCache . delete ( cacheKey ) ;
cached = null ;
}
let items : SkillsCatalogItem [ ] = [ ] ;
if ( cached ) {
items = cached . items ;
} else {
const scanned = await scanSkillsRepository ( { source : src.source , defaultSubpath : src.defaultSubpath } ) ;
if ( ! scanned . ok ) {
itemsBySource [ src . id ] = [ ] ;
continue ;
}
items = scanned . items || [ ] ;
catalogCache . set ( cacheKey , { expiresAt : Date.now ( ) + CATALOG_TTL_MS , items } ) ;
}
itemsBySource [ src . id ] = items . map ( ( item ) = > {
const installed = installedByName . get ( item . skillName ) ;
return {
sourceId : src.id ,
. . . item ,
installed : installed ? { isInstalled : true , scope : installed.scope } : { isInstalled : false } ,
} ;
} ) ;
}
return { ok : true as const , sources , itemsBySource } ;
}