import { isAbsolute, resolve } from 'path'; import type { ParsedSource } from './types.ts'; import { getGitHubHost, isGitHubHost } from './github-host.ts'; /** * Extract owner/repo (or group/subgroup/repo for GitLab) from a parsed source * for lockfile tracking and telemetry. * Returns null for local paths or unparseable sources. * Supports any Git host with an owner/repo URL structure, including GitLab subgroups. */ export function getOwnerRepo(parsed: ParsedSource): string | null { if (parsed.type === 'local' || parsed.type === 'download') { return null; } // Handle Git SSH URLs (e.g., git@gitlab.com:owner/repo.git, git@github.com:owner/repo.git) const sshMatch = parsed.url.match(/^git@[^:]+:(.+)$/); if (sshMatch) { let path = sshMatch[1]!; path = path.replace(/\.git$/, ''); // Must have at least owner/repo (one slash) if (path.includes('/')) { return path; } return null; } // Handle SSH URLs with a scheme (e.g., ssh://git@host:7999/owner/repo.git) if (parsed.url.startsWith('ssh://')) { try { const url = new URL(parsed.url); let path = url.pathname.slice(1); path = path.replace(/\.git$/, ''); if (path.includes('/')) { return path; } return null; } catch { return null; } } // Handle HTTP(S) URLs if (!parsed.url.startsWith('http://') && !parsed.url.startsWith('https://')) { return null; } try { const url = new URL(parsed.url); // Get pathname, remove leading slash and trailing .git let path = url.pathname.slice(1); path = path.replace(/\.git$/, ''); // Must have at least owner/repo (one slash) if (path.includes('/')) { return path; } } catch { // Invalid URL } return null; } /** * Extract owner and repo from an owner/repo string. * Returns null if the format is invalid. */ export function parseOwnerRepo(ownerRepo: string): { owner: string; repo: string } | null { const match = ownerRepo.match(/^([^/]+)\/([^/]+)$/); if (match) { return { owner: match[1]!, repo: match[2]! }; } return null; } /** * Check if a GitHub repository is private. * Returns true if private, false if public, null if unable to determine. * Only works for GitHub repositories (GitLab not supported). */ export async function isRepoPrivate(owner: string, repo: string): Promise { try { const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`); // If repo doesn't exist or we don't have access, assume private to be safe if (!res.ok) { return null; // Unable to determine } const data = (await res.json()) as { private?: boolean }; return data.private === true; } catch { // On error, return null to indicate we couldn't determine return null; } } /** * Sanitizes a subpath to prevent path traversal attacks. * Rejects subpaths containing ".." segments that could escape the repository root. * Returns the sanitized subpath, or throws if the subpath is unsafe. */ export function sanitizeSubpath(subpath: string): string { // Normalize to forward slashes for consistent handling const normalized = subpath.replace(/\\/g, '/'); // Check each segment for ".." const segments = normalized.split('/'); for (const segment of segments) { if (segment === '..') { throw new Error( `Unsafe subpath: "${subpath}" contains path traversal segments. ` + `Subpaths must not contain ".." components.` ); } } return subpath; } /** * Check if a string represents a local file system path */ function isLocalPath(input: string): boolean { return ( isAbsolute(input) || input.startsWith('./') || input.startsWith('../') || input === '.' || input === '..' || // Windows absolute paths like C:\ or D:\ /^[a-zA-Z]:[/\\]/.test(input) ); } /** * Parse a source string into a structured format * Supports: local paths, GitHub URLs, GitLab URLs, GitHub shorthand, well-known URLs, * hosted download URLs, and direct git URLs */ // Source aliases: map common shorthand to canonical source const SOURCE_ALIASES: Record = { 'coinbase/agentWallet': 'coinbase/agentic-wallet-skills', 'vercel-labs/vercel-skills': 'vercel-labs/agent-skills', }; interface FragmentRefResult { inputWithoutFragment: string; ref?: string; skillFilter?: string; } function decodeFragmentValue(value: string): string { try { return decodeURIComponent(value); } catch { return value; } } function looksLikeGitSource(input: string): boolean { if (input.startsWith('github:') || input.startsWith('gitlab:') || input.startsWith('git@')) { return true; } if (/^ssh:\/\/.+\.git(?:$|[/?])/i.test(input)) { return true; } if (input.startsWith('http://') || input.startsWith('https://')) { try { const parsed = new URL(input); const pathname = parsed.pathname; // Only treat GitHub fragments as refs for repo/tree URLs. if (isGitHubHost(parsed.host)) { return /^\/[^/]+\/[^/]+(?:\.git)?(?:\/tree\/[^/]+(?:\/.*)?)?\/?$/.test(pathname); } // Only treat gitlab.com fragments as refs for repo/tree URLs. if (parsed.hostname === 'gitlab.com') { return /^\/.+?\/[^/]+(?:\.git)?(?:\/-\/tree\/[^/]+(?:\/.*)?)?\/?$/.test(pathname); } } catch { // Fall through to generic checks below. } } if (/^https?:\/\/.+\.git(?:$|[/?])/i.test(input)) { return true; } return ( !input.includes(':') && !input.startsWith('.') && !input.startsWith('/') && /^([^/]+)\/([^/]+)(?:\/(.+)|@(.+))?$/.test(input) ); } function parseFragmentRef(input: string): FragmentRefResult { const hashIndex = input.indexOf('#'); if (hashIndex < 0) { return { inputWithoutFragment: input }; } const inputWithoutFragment = input.slice(0, hashIndex); const fragment = input.slice(hashIndex + 1); // Treat URL fragments as git refs only for git-like sources. // This avoids changing behavior for generic well-known URLs. if (!fragment || !looksLikeGitSource(inputWithoutFragment)) { return { inputWithoutFragment: input }; } const atIndex = fragment.indexOf('@'); if (atIndex === -1) { return { inputWithoutFragment, ref: decodeFragmentValue(fragment), }; } const ref = fragment.slice(0, atIndex); const skillFilter = fragment.slice(atIndex + 1); return { inputWithoutFragment, ref: ref ? decodeFragmentValue(ref) : undefined, skillFilter: skillFilter ? decodeFragmentValue(skillFilter) : undefined, }; } function appendFragmentRef(input: string, ref?: string, skillFilter?: string): string { if (!ref) { return input; } return `${input}#${ref}${skillFilter ? `@${skillFilter}` : ''}`; } function isHostedArtifactUrl(input: string): boolean { try { const parsed = new URL(input); const host = parsed.hostname.toLowerCase(); if ( host === 'raw.githubusercontent.com' || host === 'codeload.github.com' || host === 'objects.githubusercontent.com' ) { return true; } if (host === 'github.com') { return /^\/[^/]+\/[^/]+\/(?:archive\/|raw\/|releases\/(?:download\/|latest\/download\/))/.test( parsed.pathname ); } if (host === 'gitlab.com') { return /\/-\/(?:archive|raw)\//.test(parsed.pathname); } return false; } catch { return false; } } export function parseSource(input: string): ParsedSource { // Local path: absolute, relative, or current directory if (isLocalPath(input)) { const resolvedPath = resolve(input); // Return local type even if path doesn't exist - we'll handle validation in main flow return { type: 'local', url: resolvedPath, // Store resolved path in url for consistency localPath: resolvedPath, }; } const { inputWithoutFragment, ref: fragmentRef, skillFilter: fragmentSkillFilter, } = parseFragmentRef(input); input = inputWithoutFragment; // Resolve source aliases before parsing const alias = SOURCE_ALIASES[input]; if (alias) { input = alias; } // Prefix shorthand: github:owner/repo -> owner/repo (handled by existing shorthand logic) // Also supports github:owner/repo/subpath and github:owner/repo@skill const githubPrefixMatch = input.match(/^github:(.+)$/); if (githubPrefixMatch) { return parseSource(appendFragmentRef(githubPrefixMatch[1]!, fragmentRef, fragmentSkillFilter)); } // Prefix shorthand: gitlab:owner/repo -> https://gitlab.com/owner/repo const gitlabPrefixMatch = input.match(/^gitlab:(.+)$/); if (gitlabPrefixMatch) { return parseSource( appendFragmentRef( `https://gitlab.com/${gitlabPrefixMatch[1]!}`, fragmentRef, fragmentSkillFilter ) ); } // Hosted raw files and archive/release assets must be downloaded directly, // not normalized to their parent repository and cloned. if (isHostedArtifactUrl(input)) { return { type: 'download', url: input, }; } // Explicit GitHub Enterprise URL. Use generic git handling for cloning and // updates because the GitHub.com API/blob fast paths target the public host. if (getGitHubHost() !== 'github.com' && /^https?:\/\//.test(input)) { try { const parsedUrl = new URL(input); if (isGitHubHost(parsedUrl.host) && parsedUrl.host !== 'github.com') { const segments = parsedUrl.pathname.split('/').filter(Boolean); const [owner, rawRepo, marker, ref, ...subpathSegments] = segments; if (owner && rawRepo) { const repo = rawRepo.replace(/\.git$/, ''); const isTreeUrl = marker === 'tree' && ref; return { type: 'git', url: `${parsedUrl.protocol}//${parsedUrl.host}/${owner}/${repo}.git`, ...(isTreeUrl ? { ref } : fragmentRef ? { ref: fragmentRef } : {}), ...(isTreeUrl && subpathSegments.length > 0 ? { subpath: sanitizeSubpath(subpathSegments.join('/')) } : {}), }; } } } catch { // Fall through to the remaining source formats. } } // GitHub URL with path: https://github.com/owner/repo/tree/branch/path/to/skill const githubTreeWithPathMatch = input.match(/github\.com\/([^/]+)\/([^/]+)\/tree\/([^/]+)\/(.+)/); if (githubTreeWithPathMatch) { const [, owner, repo, ref, subpath] = githubTreeWithPathMatch; return { type: 'github', url: `https://github.com/${owner}/${repo}.git`, ref: ref || fragmentRef, subpath: subpath ? sanitizeSubpath(subpath) : subpath, }; } // GitHub URL with branch only: https://github.com/owner/repo/tree/branch const githubTreeMatch = input.match(/github\.com\/([^/]+)\/([^/]+)\/tree\/([^/]+)$/); if (githubTreeMatch) { const [, owner, repo, ref] = githubTreeMatch; return { type: 'github', url: `https://github.com/${owner}/${repo}.git`, ref: ref || fragmentRef, }; } // GitHub URL: https://github.com/owner/repo const githubRepoMatch = input.match(/github\.com\/([^/]+)\/([^/]+)/); if (githubRepoMatch) { const [, owner, repo] = githubRepoMatch; const cleanRepo = repo!.replace(/\.git$/, ''); return { type: 'github', url: `https://github.com/${owner}/${cleanRepo}.git`, ...(fragmentRef ? { ref: fragmentRef } : {}), }; } // GitLab URL with path (any GitLab instance): https://gitlab.com/owner/repo/-/tree/branch/path // Key identifier is the "/-/tree/" path pattern unique to GitLab. // Supports subgroups by using a non-greedy match for the repository path. const gitlabTreeWithPathMatch = input.match( /^(https?):\/\/([^/]+)\/(.+?)\/-\/tree\/([^/]+)\/(.+)/ ); if (gitlabTreeWithPathMatch) { const [, protocol, hostname, repoPath, ref, subpath] = gitlabTreeWithPathMatch; if (hostname !== 'github.com' && repoPath) { return { type: 'gitlab', url: `${protocol}://${hostname}/${repoPath.replace(/\.git$/, '')}.git`, ref: ref || fragmentRef, subpath: subpath ? sanitizeSubpath(subpath) : subpath, }; } } // GitLab URL with branch only (any GitLab instance): https://gitlab.com/owner/repo/-/tree/branch const gitlabTreeMatch = input.match(/^(https?):\/\/([^/]+)\/(.+?)\/-\/tree\/([^/]+)$/); if (gitlabTreeMatch) { const [, protocol, hostname, repoPath, ref] = gitlabTreeMatch; if (hostname !== 'github.com' && repoPath) { return { type: 'gitlab', url: `${protocol}://${hostname}/${repoPath.replace(/\.git$/, '')}.git`, ref: ref || fragmentRef, }; } } // GitLab.com URL: https://gitlab.com/owner/repo or https://gitlab.com/group/subgroup/repo // Only for the official gitlab.com domain for user convenience. // Supports nested subgroups (e.g., gitlab.com/group/subgroup1/subgroup2/repo). const gitlabRepoMatch = input.match(/gitlab\.com\/(.+?)(?:\.git)?\/?$/); if (gitlabRepoMatch) { const repoPath = gitlabRepoMatch[1]!; // Must have at least owner/repo (one slash) if (repoPath.includes('/')) { return { type: 'gitlab', url: `https://gitlab.com/${repoPath}.git`, ...(fragmentRef ? { ref: fragmentRef } : {}), }; } } // GitHub shorthand: owner/repo, owner/repo/path/to/skill, or owner/repo@skill-name // Exclude paths that start with . or / to avoid matching local paths // GH_HOST selects a GitHub Enterprise host for shorthand inputs. Enterprise // sources use the generic git path because GitHub.com API optimizations do // not apply to them. const githubHost = getGitHubHost(); const shorthandSourceType = githubHost === 'github.com' ? 'github' : 'git'; // First check for @skill syntax: owner/repo@skill-name const atSkillMatch = input.match(/^([^/]+)\/([^/@]+)@(.+)$/); if (atSkillMatch && !input.includes(':') && !input.startsWith('.') && !input.startsWith('/')) { const [, owner, repo, skillFilter] = atSkillMatch; return { type: shorthandSourceType, url: `https://${githubHost}/${owner}/${repo}.git`, ...(fragmentRef ? { ref: fragmentRef } : {}), skillFilter: fragmentSkillFilter || skillFilter, }; } const shorthandMatch = input.match(/^([^/]+)\/([^/]+)(?:\/(.+?))?\/?$/); if (shorthandMatch && !input.includes(':') && !input.startsWith('.') && !input.startsWith('/')) { const [, owner, repo, subpath] = shorthandMatch; return { type: shorthandSourceType, url: `https://${githubHost}/${owner}/${repo}.git`, ...(fragmentRef ? { ref: fragmentRef } : {}), subpath: subpath ? sanitizeSubpath(subpath) : subpath, ...(fragmentSkillFilter ? { skillFilter: fragmentSkillFilter } : {}), }; } // Well-known skills: arbitrary HTTP(S) URLs that aren't GitHub/GitLab. // These are also valid direct download URLs: callers should try well-known // discovery first, then fall back to downloading the URL as a SKILL.md or archive. if (isWellKnownUrl(input)) { return { type: 'well-known', url: input, }; } // Fallback: treat as direct git URL return { type: 'git', url: input, ...(fragmentRef ? { ref: fragmentRef } : {}), }; } /** * Check if a URL could be a well-known skills endpoint. * Must be HTTP(S) and not a known git host (GitHub, GitLab). * Also excludes URLs that look like git repos (.git suffix). */ function isWellKnownUrl(input: string): boolean { if (!input.startsWith('http://') && !input.startsWith('https://')) { return false; } try { const parsed = new URL(input); // Exclude known git hosts that have their own handling const excludedHosts = ['github.com', 'gitlab.com', 'raw.githubusercontent.com']; if (excludedHosts.includes(parsed.hostname)) { return false; } // Don't match URLs that look like git repos (should be handled by git type) if (input.endsWith('.git')) { return false; } return true; } catch { return false; } }