Skip to content

Commit 479cbbc

Browse files
committed
fix: resolve folder-based skill slugs that lack a registry category
When a 3-part slug like coldbox/skills/contentbox-boxlang is given, the original code only tried a direct slug match and a category filter. Skills stored as <folder>~<name> in the registry have a null category, so the category filter never matched them. Adds two more fallbacks in _resolveSlugs: - Slug prefix: s.slug.listFirst("~") == thirdPart - Path prefix: s.path.startsWith(thirdPart & "/") Also adds _tryGitHubFolderInstall as a last-resort fallback that fetches the folder listing directly from the GitHub Contents API and installs each subfolder's SKILL.md when the registry returns nothing. This mirrors how npx skills add handles the same input. https://claude.ai/code/session_01MKSgregA9MFDjsHiPdTu8D
1 parent bb5867a commit 479cbbc

1 file changed

Lines changed: 152 additions & 13 deletions

File tree

commands/coldbox/ai/skills/install.cfc

Lines changed: 152 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,20 @@ component extends="coldbox-cli.models.BaseAICommand" aliases="coldbox ai skills
109109
var resolvedItems = _resolveSlugs( slugs, language )
110110

111111
if ( resolvedItems.isEmpty() ) {
112+
// Try direct GitHub folder fallback (for skills not yet indexed in the registry)
113+
var githubInstalled = _tryGitHubFolderInstall(
114+
slugs = slugs,
115+
directory = arguments.directory,
116+
manifest = manifest,
117+
force = arguments.force
118+
)
119+
if ( githubInstalled ) {
120+
saveManifest( arguments.directory, manifest )
121+
_regenerateAgents( arguments.directory, manifest )
122+
print.line()
123+
printTip( "Run 'coldbox ai skills list' to see all installed skills." )
124+
return
125+
}
112126
printError( "No matching skills found for the given slug(s) '#arguments.slug#'." )
113127
return
114128
} else {
@@ -409,20 +423,28 @@ component extends="coldbox-cli.models.BaseAICommand" aliases="coldbox ai skills
409423
} else {
410424
// Fall back to category filter
411425
var categoryMatches = repoSkills.filter( ( s ) => s?.category == thirdPart )
412-
if ( categoryMatches.len() ) {
413-
for ( var cs in categoryMatches ) {
414-
resolved.append( {
415-
owner : slugOwner,
416-
repo : slugRepo,
417-
slug : cs.slug,
418-
name : cs.name,
419-
description : cs.description ?: "",
420-
type : "core",
421-
source : ""
422-
} )
423-
}
426+
427+
// Fallback: slug prefix match (e.g. folder~skill-name when thirdPart is a folder)
428+
if ( categoryMatches.isEmpty() ) {
429+
categoryMatches = repoSkills.filter( ( s ) => s.slug.listFirst( "~" ) == thirdPart )
430+
}
431+
432+
// Fallback: path prefix match (e.g. folder/skill-name/SKILL.md)
433+
if ( categoryMatches.isEmpty() ) {
434+
categoryMatches = repoSkills.filter( ( s ) => s.path.startsWith( thirdPart & "/" ) )
435+
}
436+
437+
for ( var cs in categoryMatches ) {
438+
resolved.append( {
439+
owner : slugOwner,
440+
repo : slugRepo,
441+
slug : cs.slug,
442+
name : cs.name,
443+
description : cs.description ?: "",
444+
type : "core",
445+
source : ""
446+
} )
424447
}
425-
// If neither a direct skill nor a category matched, resolved stays empty for this slug
426448
}
427449
} else {
428450
// Explicit 4+ part slug: owner/repo/category/skill-name
@@ -474,4 +496,121 @@ component extends="coldbox-cli.models.BaseAICommand" aliases="coldbox ai skills
474496
}
475497
}
476498

499+
/**
500+
* Fallback: install skills by fetching a GitHub folder directly via the Contents API.
501+
* Used when the registry has no results for a 3-part slug (owner/repo/folder).
502+
*
503+
* @slugs Array of slug strings (only 3-part ones are processed)
504+
* @directory Target project directory
505+
* @manifest Manifest struct to update (mutated in place)
506+
* @force Overwrite existing skills if true
507+
*
508+
* @return true if at least one skill was installed
509+
*/
510+
private boolean function _tryGitHubFolderInstall(
511+
required array slugs,
512+
required string directory,
513+
required struct manifest,
514+
required boolean force
515+
){
516+
var installed = false
517+
518+
for ( var slug in arguments.slugs ) {
519+
var parts = slug.listToArray( "/" )
520+
if ( parts.len() == 3 ) {
521+
var owner = parts[ 1 ]
522+
var repo = parts[ 2 ]
523+
var folder = parts[ 3 ]
524+
525+
// Fetch folder listing from GitHub Contents API
526+
var listResult = ""
527+
cfhttp(
528+
method = "GET",
529+
url = "https://api.github.com/repos/#owner#/#repo#/contents/#folder#",
530+
result = "listResult",
531+
timeout = 15
532+
) {
533+
cfhttpparam( type = "header", name = "User-Agent", value = "coldbox-cli" );
534+
cfhttpparam( type = "header", name = "Accept", value = "application/vnd.github.v3+json" );
535+
};
536+
537+
if ( val( listResult.statusCode ) > 0 && val( listResult.statusCode ) < 400 ) {
538+
try {
539+
var items = deserializeJSON( listResult.fileContent )
540+
if ( isArray( items ) ) {
541+
var dirs = items.filter( ( i ) => i.type == "dir" )
542+
543+
if ( dirs.len() > 0 ) {
544+
printInfo( "Fetching #dirs.len()# skill(s) directly from GitHub: #owner#/#repo#/#folder#" )
545+
print.line().toConsole()
546+
547+
var successCount = 0
548+
for ( var item in dirs ) {
549+
var skillResult = ""
550+
cfhttp(
551+
method = "GET",
552+
url = "https://api.github.com/repos/#owner#/#repo#/contents/#item.path#/SKILL.md",
553+
result = "skillResult",
554+
timeout = 15
555+
) {
556+
cfhttpparam( type = "header", name = "User-Agent", value = "coldbox-cli" );
557+
cfhttpparam( type = "header", name = "Accept", value = "application/vnd.github.v3+json" );
558+
};
559+
560+
if ( val( skillResult.statusCode ) > 0 && val( skillResult.statusCode ) < 400 ) {
561+
var fileData = deserializeJSON( skillResult.fileContent )
562+
563+
if ( fileData.keyExists( "content" ) ) {
564+
var rawContent = fileData.content.replace( chr( 10 ), "" ).replace( chr( 13 ), "" )
565+
var content = toString( binaryDecode( rawContent, "base64" ) )
566+
var sha = fileData.sha ?: ""
567+
var skillName = item.name
568+
var canInstall = true
569+
570+
if ( !force ) {
571+
var existing = variables.skillManager.getSkillFilePath( directory, skillName )
572+
if ( !isNull( existing ) ) {
573+
printInfo( " → #skillName# already installed (use --force to overwrite)" )
574+
canInstall = false
575+
}
576+
}
577+
578+
if ( canInstall ) {
579+
variables.skillManager.installRemoteSkill(
580+
directory = directory,
581+
name = skillName,
582+
content = content,
583+
owner = owner,
584+
repo = repo,
585+
path = item.path,
586+
sha = sha,
587+
description = "",
588+
auditStatus = "skipped",
589+
skillType = "core",
590+
source = "",
591+
manifest = manifest
592+
)
593+
print.greenLine( " + #skillName#" )
594+
successCount++
595+
installed = true
596+
}
597+
}
598+
}
599+
}
600+
601+
if ( successCount ) {
602+
printSuccess( "Installed #successCount# skill(s) from GitHub." )
603+
}
604+
}
605+
}
606+
} catch ( any e ) {
607+
printWarn( "GitHub folder fetch failed for '#slug#': #e.message#" )
608+
}
609+
}
610+
}
611+
}
612+
613+
return installed
614+
}
615+
477616
}

0 commit comments

Comments
 (0)