diff --git a/src/electron/libs/skills-loader.ts b/src/electron/libs/skills-loader.ts index 5d1e29c..ca4b03f 100644 --- a/src/electron/libs/skills-loader.ts +++ b/src/electron/libs/skills-loader.ts @@ -24,6 +24,92 @@ function ensureCacheDir(): void { } } +// Cache for parsed marketplace URLs +interface ParsedMarketplaceUrl { + baseUrl: string; + repo: string; + branch: string; + isGitHub: boolean; +} + +const marketplaceUrlCache = new Map(); + +/** + * Parse marketplace URL to extract base URL, repo, and branch + * Supports any URL format, not just GitHub API + * + * Examples: +* - https://api.github.com/repos/vakovalskii/LocalDesk-Skills/contents/skills + * - https://api.github.com/repos/vakovalskii/LocalDesk-Skills/contents/skills?ref=feature/rlm-pdf-reader + * - https://gitlab.com/api/v4/projects/123/repository/tree?ref=main + * - https://custom-api.example.com/skills + * - http://localhost:3000/api/skills + * - http://127.0.0.1:8080/skills?ref=dev + * + * Returns: { baseUrl, repo, branch, isGitHub } + */ +function parseMarketplaceUrl(url: string): ParsedMarketplaceUrl { + // Check cache first + if (marketplaceUrlCache.has(url)) { + return marketplaceUrlCache.get(url)!; + } + + try { + const urlObj = new URL(url); + const baseUrl = `${urlObj.protocol}//${urlObj.host}`; + const branch = urlObj.searchParams.get("ref") || "main"; + + // Try to extract repo from GitHub API format: /repos/{owner}/{repo}/contents + const githubMatch = url.match(/\/repos\/([^/]+\/[^/]+)\/contents/); + if (githubMatch) { + const repo = githubMatch[1]; + const result: ParsedMarketplaceUrl = { + baseUrl, + repo, + branch, + isGitHub: true + }; + marketplaceUrlCache.set(url, result); + return result; + } + + // For non-GitHub URLs, try to extract repo from path + // Generic format: /{owner}/{repo}/... or just use the path + const pathParts = urlObj.pathname.split("/").filter(p => p); + let repo = ""; + + if (pathParts.length >= 2) { + // Try to find owner/repo pattern + repo = `${pathParts[0]}/${pathParts[1]}`; + } else if (pathParts.length === 1) { + repo = pathParts[0]; + } else { + // Fallback: use hostname as repo identifier + repo = urlObj.hostname; + } + + const result: ParsedMarketplaceUrl = { + baseUrl, + repo, + branch, + isGitHub: false + }; + marketplaceUrlCache.set(url, result); + return result; + } catch (error) { + console.error("[SkillsLoader] Failed to parse marketplace URL:", error); + // Fallback to default GitHub + const fallback: ParsedMarketplaceUrl = { + baseUrl: "https://api.github.com", + repo: "vakovalskii/LocalDesk-Skills", + branch: "main", + isGitHub: true + }; + marketplaceUrlCache.set(url, fallback); + return fallback; + } +} + /** * Parse SKILL.md frontmatter to extract metadata */ @@ -98,8 +184,12 @@ export async function fetchSkillsFromMarketplace(): Promise { console.log("[SkillsLoader] Fetching skills from:", marketplaceUrl); + // Parse marketplace URL to get base URL, repo, and branch + const parsed = parseMarketplaceUrl(marketplaceUrl); + console.log(`[SkillsLoader] Using baseUrl: ${parsed.baseUrl}, repo: ${parsed.repo}, branch: ${parsed.branch}, isGitHub: ${parsed.isGitHub}`); + try { - // Fetch skills directory listing + // Fetch skills directory listing using the marketplace URL directly const response = await fetch(marketplaceUrl, { headers: { "Accept": "application/vnd.github.v3+json", @@ -108,7 +198,7 @@ export async function fetchSkillsFromMarketplace(): Promise { }); if (!response.ok) { - throw new Error(`GitHub API error: ${response.status}`); + throw new Error(`API error: ${response.status}`); } const contents: GitHubContent[] = await response.json(); @@ -120,7 +210,92 @@ export async function fetchSkillsFromMarketplace(): Promise { // Fetch SKILL.md for each skill for (const dir of skillDirs) { try { - const skillMdUrl = `https://raw.githubusercontent.com/vakovalskii/LocalDesk-Skills/main/${dir.path}/SKILL.md`; + // Build SKILL.md URL based on source type + let skillMdUrl: string; + if (parsed.isGitHub) { + // For GitHub, use raw.githubusercontent.com + skillMdUrl = `https://raw.githubusercontent.com/${parsed.repo}/${parsed.branch}/${dir.path}/SKILL.md`; + } else if (dir.download_url) { + // For other sources, try to use download_url from API response + // Append SKILL.md to the directory path (don't replace the directory name) + const dirUrl = new URL(dir.download_url); + // dir.download_url points to the directory, append SKILL.md to it + // Preserve query parameters and hash from original download_url + skillMdUrl = `${dirUrl.origin}${dirUrl.pathname.replace(/\/$/, "")}/SKILL.md${dirUrl.search}${dirUrl.hash}`; + } else { + // Fallback: construct URL from marketplace URL base + // For localhost and custom APIs, use the marketplace URL as base and append skill path + const marketplaceUrlObj = new URL(marketplaceUrl); + const marketplaceBasePath = marketplaceUrlObj.pathname.replace(/\/$/, ""); // Remove trailing slash + const marketplaceSegments = marketplaceBasePath.split("/").filter(p => p); + const dirPathSegments = dir.path.split("/").filter(p => p); + // Only add ref parameter if the original marketplace URL had it (indicates API supports it) + const hasRefParam = marketplaceUrlObj.searchParams.has("ref"); + const refParam = hasRefParam ? `?ref=${parsed.branch}` : ""; + + // Remove common prefix between marketplace path and dir path + // Handle three cases: + // 1. dir.path starts with all marketplace segments (e.g., "api/skills/my-skill" for marketplace "/api/skills") + // 2. dir.path starts with last marketplace segment (e.g., "skills/my-skill" for marketplace "/api/skills") + // 3. dir.path exactly matches marketplace endpoint (e.g., "skills" for marketplace "/skills") + let relativePath = dir.path; + + if (marketplaceSegments.length > 0 && dirPathSegments.length >= marketplaceSegments.length) { + // Check if dir.path starts with all marketplace segments + let matchesAllSegments = true; + for (let i = 0; i < marketplaceSegments.length; i++) { + if (dirPathSegments[i] !== marketplaceSegments[i]) { + matchesAllSegments = false; + break; + } + } + + if (matchesAllSegments) { + // Remove all matching segments + relativePath = dirPathSegments.slice(marketplaceSegments.length).join("/"); + } else if (dirPathSegments.length > 0 && dirPathSegments[0] === marketplaceSegments[marketplaceSegments.length - 1]) { + // dir path starts with the last marketplace segment, remove it + relativePath = dirPathSegments.slice(1).join("/"); + } + } else if (marketplaceSegments.length > 0 && dirPathSegments.length > 0) { + // Check if dir.path starts with last marketplace segment or matches exactly + const lastMarketplaceSegment = marketplaceSegments[marketplaceSegments.length - 1]; + if (dirPathSegments[0] === lastMarketplaceSegment) { + relativePath = dirPathSegments.slice(1).join("/"); + } else if (dirPathSegments.length === marketplaceSegments.length) { + // Check if dir.path exactly matches marketplace (e.g., "skills" matches "/skills") + let matchesExactly = true; + for (let i = 0; i < dirPathSegments.length; i++) { + if (dirPathSegments[i] !== marketplaceSegments[i]) { + matchesExactly = false; + break; + } + } + if (matchesExactly) { + relativePath = ""; + } + } + } else if (marketplaceSegments.length > 0 && dirPathSegments.length === 0) { + // Empty dir.path, use empty relative path + relativePath = ""; + } + + // If relativePath is empty, it means the path matches exactly - use empty string + // This will create URL like /api/skills/SKILL.md instead of /api/skills/skills/SKILL.md + // Handle empty marketplaceBasePath (root path) to avoid double slashes + if (marketplaceBasePath === "") { + // Root path: http://localhost:3000/ + skillMdUrl = relativePath + ? `${parsed.baseUrl}/${relativePath}/SKILL.md${refParam}` + : `${parsed.baseUrl}/SKILL.md${refParam}`; + } else { + // Non-root path: http://localhost:3000/api/skills + skillMdUrl = relativePath + ? `${parsed.baseUrl}${marketplaceBasePath}/${relativePath}/SKILL.md${refParam}` + : `${parsed.baseUrl}${marketplaceBasePath}/SKILL.md${refParam}`; + } + } + const skillMdResponse = await fetch(skillMdUrl); if (skillMdResponse.ok) { @@ -174,18 +349,98 @@ export async function downloadSkill(skillId: string): Promise { throw new Error(`Skill not found: ${skillId}`); } + // Parse marketplace URL to get base URL, repo, and branch + const parsed = parseMarketplaceUrl(settings.marketplaceUrl); + ensureCacheDir(); const skillCacheDir = path.join(getCacheDir(), skillId); - console.log(`[SkillsLoader] Downloading skill: ${skillId}`); + console.log(`[SkillsLoader] Downloading skill: ${skillId} from ${parsed.repo}/${parsed.branch}`); // Create skill cache directory if (!fs.existsSync(skillCacheDir)) { fs.mkdirSync(skillCacheDir, { recursive: true }); } - // Fetch skill directory contents - const contentsUrl = `https://api.github.com/repos/vakovalskii/LocalDesk-Skills/contents/${skill.repoPath}`; + // Build contents URL based on source type + let contentsUrl: string; + if (parsed.isGitHub) { + // For GitHub, use GitHub API format + contentsUrl = `${parsed.baseUrl}/repos/${parsed.repo}/contents/${skill.repoPath}?ref=${parsed.branch}`; + } else { + // For other sources, construct URL by appending skill path to marketplace base + // skill.repoPath is a full path from repo root (e.g., "skills/skill-name") + // We need to extract the relative part relative to marketplace endpoint + const marketplaceUrlObj = new URL(settings.marketplaceUrl); + const marketplaceBasePath = marketplaceUrlObj.pathname.replace(/\/$/, ""); + const marketplaceSegments = marketplaceBasePath.split("/").filter(p => p); + const skillPathSegments = skill.repoPath.split("/").filter(p => p); + // Only add ref parameter if the original marketplace URL had it (indicates API supports it) + const hasRefParam = marketplaceUrlObj.searchParams.has("ref"); + const refParam = hasRefParam ? `?ref=${parsed.branch}` : ""; + + // Remove common prefix between marketplace path and skill path + // Handle three cases: + // 1. skill.repoPath starts with all marketplace segments (e.g., "api/skills/skill-name" for marketplace "/api/skills") + // 2. skill.repoPath starts with last marketplace segment (e.g., "skills/skill-name" for marketplace "/api/skills") + // 3. skill.repoPath exactly matches marketplace endpoint (e.g., "skills" for marketplace "/skills") + let relativePath = skill.repoPath; + + if (marketplaceSegments.length > 0 && skillPathSegments.length >= marketplaceSegments.length) { + // Check if skill.repoPath starts with all marketplace segments + let matchesAllSegments = true; + for (let i = 0; i < marketplaceSegments.length; i++) { + if (skillPathSegments[i] !== marketplaceSegments[i]) { + matchesAllSegments = false; + break; + } + } + + if (matchesAllSegments) { + // Remove all matching segments + relativePath = skillPathSegments.slice(marketplaceSegments.length).join("/"); + } else if (skillPathSegments.length > 0 && skillPathSegments[0] === marketplaceSegments[marketplaceSegments.length - 1]) { + // skill path starts with the last marketplace segment, remove it + relativePath = skillPathSegments.slice(1).join("/"); + } + } else if (marketplaceSegments.length > 0 && skillPathSegments.length > 0) { + // Check if skill.repoPath starts with last marketplace segment or matches exactly + const lastMarketplaceSegment = marketplaceSegments[marketplaceSegments.length - 1]; + if (skillPathSegments[0] === lastMarketplaceSegment) { + relativePath = skillPathSegments.slice(1).join("/"); + } else if (skillPathSegments.length === marketplaceSegments.length) { + // Check if skill.repoPath exactly matches marketplace (e.g., "skills" matches "/skills") + let matchesExactly = true; + for (let i = 0; i < skillPathSegments.length; i++) { + if (skillPathSegments[i] !== marketplaceSegments[i]) { + matchesExactly = false; + break; + } + } + if (matchesExactly) { + relativePath = ""; + } + } + } else if (marketplaceSegments.length > 0 && skillPathSegments.length === 0) { + // Empty skill.repoPath, use empty relative path + relativePath = ""; + } + + // If relativePath is empty, it means the path matches exactly - use empty string + // Handle empty marketplaceBasePath (root path) to avoid double slashes + if (marketplaceBasePath === "") { + // Root path: http://localhost:3000/ + contentsUrl = relativePath + ? `${parsed.baseUrl}/${relativePath}${refParam}` + : `${parsed.baseUrl}${refParam}`; + } else { + // Non-root path: http://localhost:3000/api/skills + contentsUrl = relativePath + ? `${parsed.baseUrl}${marketplaceBasePath}/${relativePath}${refParam}` + : `${parsed.baseUrl}${marketplaceBasePath}${refParam}`; + } + } + const response = await fetch(contentsUrl, { headers: { "Accept": "application/vnd.github.v3+json", @@ -194,13 +449,27 @@ export async function downloadSkill(skillId: string): Promise { }); if (!response.ok) { - throw new Error(`GitHub API error: ${response.status}`); + throw new Error(`API error: ${response.status}`); } const contents: GitHubContent[] = await response.json(); // Download all files recursively - await downloadContents(contents, skillCacheDir, skill.repoPath); + const errors = await downloadContents(contents, skillCacheDir, skill.repoPath, parsed, settings.marketplaceUrl); + + // Check if SKILL.md was downloaded (critical file) + const skillMdPath = path.join(skillCacheDir, "SKILL.md"); + if (!fs.existsSync(skillMdPath)) { + throw new Error(`Failed to download skill ${skillId}: SKILL.md not found. This is a critical file.`); + } + + // Report errors if any files failed to download + if (errors.length > 0) { + console.warn(`[SkillsLoader] Skill ${skillId} downloaded with ${errors.length} error(s):`); + errors.forEach(err => console.warn(` - ${err}`)); + // Don't throw - allow partial downloads, but log warnings + // The skill may still be usable if critical files (like SKILL.md) are present + } return skillCacheDir; } @@ -208,36 +477,145 @@ export async function downloadSkill(skillId: string): Promise { async function downloadContents( contents: GitHubContent[], targetDir: string, - basePath: string -): Promise { + basePath: string, + parsed: ParsedMarketplaceUrl, + marketplaceUrl: string +): Promise { + const errors: string[] = []; + for (const item of contents) { const localPath = path.join(targetDir, item.name); if (item.type === "file" && item.download_url) { - // Download file - const response = await fetch(item.download_url); - const content = await response.text(); - fs.writeFileSync(localPath, content, "utf-8"); + // Download file using download_url from API response + try { + const response = await fetch(item.download_url); + if (!response.ok) { + const errorMsg = `Failed to download file ${item.name} (${item.path}): ${response.status} ${response.statusText}`; + console.warn(`[SkillsLoader] ${errorMsg}`); + errors.push(errorMsg); + continue; // Skip this file and continue with others + } + const content = await response.text(); + fs.writeFileSync(localPath, content, "utf-8"); + } catch (error: unknown) { + const errorMsg = `Failed to download file ${item.name} (${item.path}): ${error instanceof Error ? error.message : String(error)}`; + console.warn(`[SkillsLoader] ${errorMsg}`); + errors.push(errorMsg); + continue; // Skip this file and continue with others + } } else if (item.type === "dir") { // Create directory and fetch its contents if (!fs.existsSync(localPath)) { fs.mkdirSync(localPath, { recursive: true }); } - const subContentsUrl = `https://api.github.com/repos/vakovalskii/LocalDesk-Skills/contents/${item.path}`; - const subResponse = await fetch(subContentsUrl, { - headers: { - "Accept": "application/vnd.github.v3+json", - "User-Agent": "LocalDesk" + // Build sub-contents URL based on source type + let subContentsUrl: string; + if (parsed.isGitHub) { + // For GitHub, use GitHub API format + subContentsUrl = `${parsed.baseUrl}/repos/${parsed.repo}/contents/${item.path}?ref=${parsed.branch}`; + } else { + // For other sources (including localhost), construct from marketplace URL base + // item.path from API is a full path from repository root (e.g., "skills/skill-name/subdir") + // We need to extract the relative part relative to marketplace endpoint + + const marketplaceUrlObj = new URL(marketplaceUrl); + const marketplaceBasePath = marketplaceUrlObj.pathname.replace(/\/$/, ""); // Remove trailing slash + const marketplaceSegments = marketplaceBasePath.split("/").filter(p => p); + const itemPathSegments = item.path.split("/").filter(p => p); + // Only add ref parameter if the original marketplace URL had it (indicates API supports it) + const hasRefParam = marketplaceUrlObj.searchParams.has("ref"); + const refParam = hasRefParam ? `?ref=${parsed.branch}` : ""; + + // Remove common prefix between marketplace path and item path + // Handle three cases: + // 1. item.path starts with all marketplace segments (e.g., "api/skills/skill-name/subdir" for marketplace "/api/skills") + // 2. item.path starts with last marketplace segment (e.g., "skills/skill-name/subdir" for marketplace "/api/skills") + // 3. item.path exactly matches marketplace endpoint (e.g., "skills" for marketplace "/skills") + let relativePath = item.path; + + if (marketplaceSegments.length > 0 && itemPathSegments.length >= marketplaceSegments.length) { + // Check if item.path starts with all marketplace segments + let matchesAllSegments = true; + for (let i = 0; i < marketplaceSegments.length; i++) { + if (itemPathSegments[i] !== marketplaceSegments[i]) { + matchesAllSegments = false; + break; + } + } + + if (matchesAllSegments) { + // Remove all matching segments + relativePath = itemPathSegments.slice(marketplaceSegments.length).join("/"); + } else if (itemPathSegments.length > 0 && itemPathSegments[0] === marketplaceSegments[marketplaceSegments.length - 1]) { + // item path starts with the last marketplace segment, remove it + relativePath = itemPathSegments.slice(1).join("/"); + } + } else if (marketplaceSegments.length > 0 && itemPathSegments.length > 0) { + // Check if item.path starts with last marketplace segment or matches exactly + const lastMarketplaceSegment = marketplaceSegments[marketplaceSegments.length - 1]; + if (itemPathSegments[0] === lastMarketplaceSegment) { + relativePath = itemPathSegments.slice(1).join("/"); + } else if (itemPathSegments.length === marketplaceSegments.length) { + // Check if item.path exactly matches marketplace (e.g., "skills" matches "/skills") + let matchesExactly = true; + for (let i = 0; i < itemPathSegments.length; i++) { + if (itemPathSegments[i] !== marketplaceSegments[i]) { + matchesExactly = false; + break; + } + } + if (matchesExactly) { + relativePath = ""; + } + } + } else if (marketplaceSegments.length > 0 && itemPathSegments.length === 0) { + // Empty item.path, use empty relative path + relativePath = ""; } - }); + + // If relativePath is empty, it means the path matches exactly - use empty string + // Handle empty marketplaceBasePath (root path) to avoid double slashes + if (marketplaceBasePath === "") { + // Root path: http://localhost:3000/ + subContentsUrl = relativePath + ? `${parsed.baseUrl}/${relativePath}${refParam}` + : `${parsed.baseUrl}${refParam}`; + } else { + // Non-root path: http://localhost:3000/api/skills + subContentsUrl = relativePath + ? `${parsed.baseUrl}${marketplaceBasePath}/${relativePath}${refParam}` + : `${parsed.baseUrl}${marketplaceBasePath}${refParam}`; + } + } - if (subResponse.ok) { - const subContents: GitHubContent[] = await subResponse.json(); - await downloadContents(subContents, localPath, item.path); + try { + const subResponse = await fetch(subContentsUrl, { + headers: { + "Accept": "application/vnd.github.v3+json", + "User-Agent": "LocalDesk" + } + }); + + if (subResponse.ok) { + const subContents: GitHubContent[] = await subResponse.json(); + const subErrors = await downloadContents(subContents, localPath, item.path, parsed, marketplaceUrl); + errors.push(...subErrors); + } else { + const errorMsg = `Failed to fetch directory ${item.name} (${item.path}): ${subResponse.status} ${subResponse.statusText}`; + console.warn(`[SkillsLoader] ${errorMsg}`); + errors.push(errorMsg); + } + } catch (error: unknown) { + const errorMsg = `Failed to fetch directory ${item.name} (${item.path}): ${error instanceof Error ? error.message : String(error)}`; + console.warn(`[SkillsLoader] ${errorMsg}`); + errors.push(errorMsg); } } } + + return errors; } /**