From 790832b0f97e0df798ba5566df549da9a976f71c Mon Sep 17 00:00:00 2001 From: catlog22 Date: Tue, 24 Feb 2026 13:58:00 +0800 Subject: [PATCH] fix(skill-hub): correct index.json path and support full directory download - Fixed GITHUB_CONFIG.skillIndexPath from 'index.json' to 'skill-hub/index.json' - Added RemoteSkillEntry.path field for directory-based skills - Added buildDownloadUrlFromPath() helper function - Added GitHub API-based directory download with downloadSkillDirectory() - Added installSkillFromRemotePath() for full skill directory installation - Modified install route to support both downloadUrl and path-based installation --- ccw/src/core/routes/skill-hub-routes.ts | 175 +++++++++++++++++++++++- 1 file changed, 172 insertions(+), 3 deletions(-) diff --git a/ccw/src/core/routes/skill-hub-routes.ts b/ccw/src/core/routes/skill-hub-routes.ts index af6e088c..d3280f44 100644 --- a/ccw/src/core/routes/skill-hub-routes.ts +++ b/ccw/src/core/routes/skill-hub-routes.ts @@ -95,7 +95,8 @@ export interface RemoteSkillEntry { author: string; category: string; tags: string[]; - downloadUrl: string; + downloadUrl?: string; + path?: string; // Relative path to skill directory in repo readmeUrl?: string; homepage?: string; license?: string; @@ -174,7 +175,7 @@ const GITHUB_CONFIG = { owner: 'catlog22', repo: 'skill-hub', branch: 'main', - skillIndexPath: 'index.json' + skillIndexPath: 'skill-hub/index.json' }; /** @@ -401,6 +402,93 @@ async function fetchRemoteSkill(downloadUrl: string): Promise { return response.text(); } +/** + * Build download URL from skill path + */ +function buildDownloadUrlFromPath(skillPath: string): string { + return `https://raw.githubusercontent.com/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/${GITHUB_CONFIG.branch}/${skillPath}/SKILL.md`; +} + +/** + * Fetch skill directory contents from GitHub API + * Returns list of files in the directory + */ +interface GitHubTreeEntry { + path: string; + mode: string; + type: 'blob' | 'tree'; + sha: string; + size?: number; + url: string; +} + +async function fetchSkillDirectoryContents(skillPath: string): Promise { + // Use GitHub API to get tree contents + const apiUrl = `https://api.github.com/repos/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/contents/${skillPath}?ref=${GITHUB_CONFIG.branch}`; + + const response = await fetch(apiUrl, { + headers: { + 'Accept': 'application/vnd.github.v3+json', + 'User-Agent': 'CCW-SkillHub/1.0', + }, + }); + + if (!response.ok) { + throw new Error(`GitHub API error: ${response.status} ${response.statusText}`); + } + + return response.json(); +} + +/** + * Download all files from a skill directory + */ +async function downloadSkillDirectory( + skillPath: string, + targetDir: string +): Promise<{ success: boolean; files: string[] }> { + const files: string[] = []; + + try { + const contents = await fetchSkillDirectoryContents(skillPath); + + for (const entry of contents) { + if (entry.type === 'blob') { + // Download file + const fileUrl = `https://raw.githubusercontent.com/${GITHUB_CONFIG.owner}/${GITHUB_CONFIG.repo}/${GITHUB_CONFIG.branch}/${entry.path}`; + const response = await fetch(fileUrl); + + if (response.ok) { + const content = await response.text(); + const filePath = join(targetDir, entry.path.replace(skillPath + '/', '')); + const fileDir = dirname(filePath); + + // Ensure directory exists + if (!existsSync(fileDir)) { + mkdirSync(fileDir, { recursive: true }); + } + + writeFileSync(filePath, content, 'utf8'); + files.push(entry.path); + } + } else if (entry.type === 'tree') { + // Recursively download subdirectory + const subDir = join(targetDir, entry.path.replace(skillPath + '/', '')); + if (!existsSync(subDir)) { + mkdirSync(subDir, { recursive: true }); + } + const subResult = await downloadSkillDirectory(entry.path, targetDir); + files.push(...subResult.files); + } + } + + return { success: true, files }; + } catch (error) { + console.error('[SkillHub] Failed to download directory:', error); + return { success: false, files }; + } +} + // ============================================================================ // Local Skills Helpers // ============================================================================ @@ -686,6 +774,78 @@ async function installSkillFromRemote( } } +/** + * Install skill from remote path (downloads entire directory) + */ +async function installSkillFromRemotePath( + skillPath: string, + cliType: CliType, + skillId: string, + customName?: string +): Promise<{ success: boolean; message: string; installedPath?: string }> { + try { + // Validate skillId for path safety + if (!isValidSkillName(skillId.replace('remote-', '').replace('local-', ''))) { + console.error('[SkillHub] Invalid skill ID rejected:', skillId); + return { success: false, message: 'Invalid skill ID' }; + } + + // Get target directory + const targetDir = getCliSkillsDir(cliType); + const skillName = customName || skillId; + const targetSkillDir = join(targetDir, skillName); + + // Create target directory if needed + if (!existsSync(targetDir)) { + mkdirSync(targetDir, { recursive: true }); + } + + // Check if already exists + if (existsSync(targetSkillDir)) { + return { success: false, message: `Skill '${skillName}' already exists in ${cliType}` }; + } + + // Create skill directory + mkdirSync(targetSkillDir, { recursive: true }); + + // Download entire skill directory + console.log(`[SkillHub] Downloading skill directory: ${skillPath}`); + const result = await downloadSkillDirectory(skillPath, targetSkillDir); + + if (!result.success || result.files.length === 0) { + // Fallback: download only SKILL.md + console.log('[SkillHub] Directory download failed, falling back to SKILL.md only'); + const skillMdUrl = buildDownloadUrlFromPath(skillPath); + const skillContent = await fetchRemoteSkill(skillMdUrl); + writeFileSync(join(targetSkillDir, 'SKILL.md'), skillContent, 'utf8'); + } + + // Cache the skill locally + try { + ensureSkillHubDirs(); + const cachedDir = join(getCachedSkillsDir(), skillId); + if (!existsSync(cachedDir)) { + mkdirSync(cachedDir, { recursive: true }); + } + // Copy entire skill directory to cache + cpSync(targetSkillDir, cachedDir, { recursive: true }); + } catch (cacheError) { + console.error('[SkillHub] Failed to cache skill:', cacheError instanceof Error ? cacheError.message : String(cacheError)); + } + + return { + success: true, + message: `Skill '${skillName}' installed to ${cliType} (${result.files.length} files)`, + installedPath: targetSkillDir, + }; + } catch (error) { + return { + success: false, + message: sanitizeErrorMessage(error, 'Skill installation'), + }; + } +} + // ============================================================================ // Updates Check Helpers // ============================================================================ @@ -855,6 +1015,7 @@ export async function handleSkillHubRoutes(ctx: RouteContext): Promise } else { // Install from remote let url = downloadUrl; + let skillPath: string | undefined; if (!url) { // Fetch from remote index @@ -866,9 +1027,17 @@ export async function handleSkillHubRoutes(ctx: RouteContext): Promise } url = remoteSkill.downloadUrl; + skillPath = remoteSkill.path; } - result = await installSkillFromRemote(url, cliType, skillId, customName); + // Prefer path-based installation for full directory download + if (skillPath && !url) { + result = await installSkillFromRemotePath(skillPath, cliType, skillId, customName); + } else if (url) { + result = await installSkillFromRemote(url, cliType, skillId, customName); + } else { + return { success: false, error: 'No downloadUrl or path available for skill', status: 400 }; + } } if (result.success) {