From f059bc8dd63c47cb96c56f6a912cfe83a12cf4ba Mon Sep 17 00:00:00 2001 From: kjhq Date: Wed, 13 May 2026 12:49:31 +0530 Subject: [PATCH] fix: remove GitHub hardcoding and generalize for non-GitHub git providers (#1216) * fix: remove GitHub hardcoding and generalize for non-GitHub git providers * fix: guard SSH path extraction against missing colon separator --------- Co-authored-by: artac --- .../skills/catalog/AddCatalogDialog.tsx | 16 +++-- packages/vscode/src/skillsCatalog.ts | 49 +++++++++------ .../lib/skills-catalog/DOCUMENTATION.md | 22 +++---- .../web/server/lib/skills-catalog/source.js | 62 ++++++++++--------- 4 files changed, 83 insertions(+), 66 deletions(-) diff --git a/packages/ui/src/components/sections/skills/catalog/AddCatalogDialog.tsx b/packages/ui/src/components/sections/skills/catalog/AddCatalogDialog.tsx index 37b2aab6..2c2c0c88 100644 --- a/packages/ui/src/components/sections/skills/catalog/AddCatalogDialog.tsx +++ b/packages/ui/src/components/sections/skills/catalog/AddCatalogDialog.tsx @@ -32,13 +32,17 @@ const generateCatalogId = () => `custom:${Date.now()}-${Math.random().toString(1 const guessLabelFromSource = (value: string) => { const trimmed = value.trim(); - const ssh = trimmed.match(/^git@github\.com:([^/\s]+)\/([^\s#]+)$/i); - if (ssh) { - return `${ssh[1]}/${ssh[2].replace(/\.git$/i, '')}`; + const urlFormat = trimmed.startsWith("https://") + ? "https" + : trimmed.startsWith("git@") + ? "ssh" + : "shorthand"; + + if (urlFormat === 'ssh') { + return `${trimmed.split(":")[1].replace(/\.git$/i, '')}`; } - const https = trimmed.match(/^https?:\/\/github\.com\/([^/\s]+)\/([^\s#]+)$/i); - if (https) { - return `${https[1]}/${https[2].replace(/\.git$/i, '')}`; + if (urlFormat === 'https') { + return trimmed.split('/').slice(3).filter(Boolean).join('/').replace(/\.git$/i, ''); } const shorthand = trimmed.match(/^([^/\s]+)\/([^/\s]+)(?:\/.+)?$/); if (shorthand) { diff --git a/packages/vscode/src/skillsCatalog.ts b/packages/vscode/src/skillsCatalog.ts index 266b7fab..e499f2de 100644 --- a/packages/vscode/src/skillsCatalog.ts +++ b/packages/vscode/src/skillsCatalog.ts @@ -438,33 +438,44 @@ function parseSkillRepoSource(input: string, subpath?: string) { if (!raw) { return { ok: false as const, error: { kind: 'invalidSource' as const, message: 'Repository source is required' } }; } + const urlFormat = raw.startsWith('https://') ? 'https' : raw.startsWith('git@') ? 'ssh' : 'shorthand'; + const gitHost = urlFormat === 'https' ? raw.split('/')[2] : urlFormat === 'ssh' ? raw.split('@')[1].split(':')[0] : null; + + if (gitHost === null && urlFormat !== 'shorthand') { + return { ok: false as const, error: { kind: 'invalidSource' as const, message: 'Unsupported repository source format'} }; + } + + const pathSegments = urlFormat === 'https' + ? raw.split('/').slice(3).filter(Boolean) + : urlFormat === 'ssh' + ? (raw.split('@')[1].split(':')[1] ?? '').split('/').filter(Boolean) + : null; + + const repoName = pathSegments && pathSegments.length > 0 + ? pathSegments[pathSegments.length - 1].replace(/\.git$/i, '') + : null; + + const gitOwner = pathSegments && pathSegments.length > 1 + ? pathSegments.slice(0, -1).join('/') + : (pathSegments && pathSegments.length === 1 ? pathSegments[0] : null); 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, - }; - } + if (urlFormat === 'ssh' || urlFormat === 'https') { + const owner = (gitOwner || '').trim(); + const repo = (repoName || '').trim(); + + if (!owner || !repo) { + return { ok: false as const, error: { kind: 'invalidSource' as const, message: `Invalid ${urlFormat} repository format.` } }; + } - 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`, + cloneUrlHttps: `https://${gitHost}/${owner}/${repo}.git`, + cloneUrlSsh: `git@${gitHost}:${owner}/${repo}.git`, effectiveSubpath: explicitSubpath, - }; + } } const shorthandMatch = raw.match(/^([^/\s]+)\/([^/\s]+)(?:\/(.+))?$/); diff --git a/packages/web/server/lib/skills-catalog/DOCUMENTATION.md b/packages/web/server/lib/skills-catalog/DOCUMENTATION.md index 02ff1d62..4b4d1231 100644 --- a/packages/web/server/lib/skills-catalog/DOCUMENTATION.md +++ b/packages/web/server/lib/skills-catalog/DOCUMENTATION.md @@ -1,16 +1,16 @@ # Skills Catalog Module Documentation ## Purpose -This module provides skill discovery, scanning, and installation capabilities for OpenCode. It supports multiple skill sources including GitHub repositories and the ClawdHub registry, with caching and conflict resolution for skill installation. +This module provides skill discovery, scanning, and installation capabilities for OpenCode. It supports multiple skill sources including git repositories and the ClawdHub registry, with caching and conflict resolution for skill installation. ## Entrypoints and structure - `packages/web/server/lib/skills-catalog/`: Skills catalog module directory containing all skill-related functionality. - `cache.js`: In-memory cache for scan results with TTL support. - `curated-sources.js`: Predefined skill sources (Anthropic, ClawdHub). - `git.js`: Git operations helpers for cloning and auth error detection. - - `install.js`: Skills installation from GitHub repositories. - - `scan.js`: Skills scanning from GitHub repositories. - - `source.js`: Source string parsing for GitHub repositories. + - `install.js`: Skills installation from git repositories. + - `scan.js`: Skills scanning from git repositories. + - `source.js`: Source string parsing for git repositories. - `clawdhub/`: ClawdHub registry integration. - `index.js`: Public API exports for ClawdHub. - `scan.js`: Scanning ClawdHub registry with pagination. @@ -32,13 +32,13 @@ The following functions are exported and used by the web server: - `CURATED_SKILLS_SOURCES`: Constant array of predefined sources. ### Source Parsing (`source.js`) -- `parseSkillRepoSource(source, { subpath })`: Parse GitHub repository source string into structured object with SSH/HTTPS clone URLs, normalized repo, and effective subpath. Supports SSH URLs, HTTPS URLs, and shorthand `owner/repo[/subpath]` format. +- `parseSkillRepoSource(source, { subpath })`: Parse git repository source string into structured object with SSH/HTTPS clone URLs, normalized repo, and effective subpath. Supports SSH URLs, HTTPS URLs, and shorthand `owner/repo[/subpath]` format. ### Git Repository Scanning (`scan.js`) -- `scanSkillsRepository({ source, subpath, defaultSubpath, identity })`: Scan GitHub repository for skills by cloning and analyzing SKILL.md files. Returns array of skill items with metadata. +- `scanSkillsRepository({ source, subpath, defaultSubpath, identity })`: Scan git repository for skills by cloning and analyzing SKILL.md files. Returns array of skill items with metadata. ### Git Repository Installation (`install.js`) -- `installSkillsFromRepository({ source, subpath, defaultSubpath, identity, scope, targetSource, workingDirectory, userSkillDir, selections, conflictPolicy, conflictDecisions })`: Install skills from GitHub repository. Supports user/project scopes, opencode/agents targets, conflict resolution (prompt/skipAll/overwriteAll), and sparse checkout for efficiency. +- `installSkillsFromRepository({ source, subpath, defaultSubpath, identity, scope, targetSource, workingDirectory, userSkillDir, selections, conflictPolicy, conflictDecisions })`: Install skills from git repository. Supports user/project scopes, opencode/agents targets, conflict resolution (prompt/skipAll/overwriteAll), and sparse checkout for efficiency. ### ClawdHub Integration (`clawdhub/index.js`) - `isClawdHubSource(source)`: Check if source string refers to ClawdHub. @@ -73,7 +73,7 @@ The following functions are internal helpers used by exported functions: - `normalizeUserSkillDir(userSkillDir)`: Normalize user skill directory path (handles legacy `~/.config/opencode/skill` → `~/.config/opencode/skills` migration). ### Git Clone Helpers (`install.js`, `scan.js`) -- `cloneRepo({ cloneUrl, identity, tempDir })`: Clone GitHub repository with preferred partial clone (`--filter=blob:none`) and fallback. Uses non-interactive mode. +- `cloneRepo({ cloneUrl, identity, tempDir })`: Clone git repository with preferred partial clone (`--filter=blob:none`) and fallback. Uses non-interactive mode. ### SKILL.md Parsing (`scan.js`) - `parseSkillMd(content)`: Parse YAML frontmatter from SKILL.md content. Returns `{ ok, frontmatter, warnings }`. @@ -90,7 +90,7 @@ The following functions are internal helpers used by exported functions: ### Scan Skills Repository Response - `ok`: Boolean indicating success. -- `normalizedRepo`: Normalized GitHub repo string (`owner/repo`). +- `normalizedRepo`: Normalized repo string (`owner/repo`). - `effectiveSubpath`: Effective subpath used for scanning (may be from source string or defaultSubpath). - `items`: Array of skill items with `{ repoSource, repoSubpath, skillDir, skillName, frontmatterName, description, installable, warnings }`. - `error`: Error object with `{ kind, message }` on failure. @@ -109,7 +109,7 @@ The following functions are internal helpers used by exported functions: ### Parse Source Response - `ok`: Boolean indicating success. -- `host`: GitHub host (`github.com`). +- `host`: Git host (e.g., `github.com`, `gitlab.com`). - `owner`: Repository owner. - `repo`: Repository name. - `cloneUrlSsh`: SSH clone URL. @@ -129,7 +129,7 @@ The following functions are internal helpers used by exported functions: ### Skill Name Validation - All skill names must match `/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/` (1-64 chars). -- Skill names are derived from directory basenames for GitHub repos and slugs for ClawdHub. +- Skill names are derived from directory basenames for git repos and slugs for ClawdHub. - Invalid names result in non-installable skills with appropriate warnings. ### Git Cloning Strategy diff --git a/packages/web/server/lib/skills-catalog/source.js b/packages/web/server/lib/skills-catalog/source.js index 701e96ab..47e6f540 100644 --- a/packages/web/server/lib/skills-catalog/source.js +++ b/packages/web/server/lib/skills-catalog/source.js @@ -1,6 +1,7 @@ const GITHUB_HOST = 'github.com'; -function normalizeGitHubOwnerRepo(owner, repo) { + +function normalizeGitOwnerRepo(owner, repo) { const normalizedOwner = String(owner || '').trim(); const normalizedRepo = String(repo || '').trim().replace(/\.git$/i, ''); if (!normalizedOwner || !normalizedRepo) { @@ -9,50 +10,51 @@ function normalizeGitHubOwnerRepo(owner, repo) { return { owner: normalizedOwner, repo: normalizedRepo }; } + export function parseSkillRepoSource(input, options = {}) { const raw = typeof input === 'string' ? input.trim() : ''; if (!raw) { return { ok: false, error: { kind: 'invalidSource', message: 'Repository source is required' } }; } - const explicitSubpath = typeof options.subpath === 'string' && options.subpath.trim() ? options.subpath.trim() : null; + + const urlFormat = raw.startsWith('https://') ? 'https' : raw.startsWith('git@') ? 'ssh' : 'shorthand'; + const gitHost = urlFormat === 'https' ? raw.split('/')[2] : urlFormat === 'ssh' ? raw.split('@')[1].split(':')[0] : null; - // SSH URL: git@github.com:owner/repo(.git) - const sshMatch = raw.match(/^git@github\.com:([^/\s]+)\/([^\s#]+)$/i); - if (sshMatch) { - const parsed = normalizeGitHubOwnerRepo(sshMatch[1], sshMatch[2]); - if (!parsed) { - return { ok: false, error: { kind: 'invalidSource', message: 'Invalid SSH repository URL' } }; - } - - return { - ok: true, - host: GITHUB_HOST, - owner: parsed.owner, - repo: parsed.repo, - cloneUrlSsh: `git@github.com:${parsed.owner}/${parsed.repo}.git`, - cloneUrlHttps: `https://github.com/${parsed.owner}/${parsed.repo}.git`, - // For SSH URLs, subpath is only accepted via options.subpath - effectiveSubpath: explicitSubpath, - normalizedRepo: `${parsed.owner}/${parsed.repo}`, - }; + if (gitHost === null && urlFormat !== 'shorthand') { + return { ok: false, error: { kind: 'invalidSource', message: 'Invalid repository URL format' } }; } - // HTTPS URL: https://github.com/owner/repo(.git) - const httpsMatch = raw.match(/^https?:\/\/github\.com\/([^/\s]+)\/([^\s#]+)$/i); - if (httpsMatch) { - const parsed = normalizeGitHubOwnerRepo(httpsMatch[1], httpsMatch[2]); + const pathSegments = urlFormat === 'https' + ? raw.split('/').slice(3).filter(Boolean) + : urlFormat === 'ssh' + ? (raw.split('@')[1].split(':')[1] ?? '').split('/').filter(Boolean) + : null; + + const repoName = pathSegments && pathSegments.length > 0 + ? pathSegments[pathSegments.length - 1].replace(/\.git$/i, '') + : null; + + const gitOwner = pathSegments && pathSegments.length > 1 + ? pathSegments.slice(0, -1).join('/') + : (pathSegments && pathSegments.length === 1 ? pathSegments[0] : null); + + + // SSH git@host:owner/repo(.git) or HTTPS https://host/owner/repo(.git) + if (urlFormat === 'ssh' || urlFormat === 'https') { + const parsed = normalizeGitOwnerRepo(gitOwner, repoName); if (!parsed) { - return { ok: false, error: { kind: 'invalidSource', message: 'Invalid HTTPS repository URL' } }; + return { ok: false, error: { kind: 'invalidSource', message: `Invalid ${urlFormat} repository URL` } }; } return { ok: true, - host: GITHUB_HOST, + host: gitHost, owner: parsed.owner, repo: parsed.repo, - cloneUrlSsh: `git@github.com:${parsed.owner}/${parsed.repo}.git`, - cloneUrlHttps: `https://github.com/${parsed.owner}/${parsed.repo}.git`, + cloneUrlSsh: `git@${gitHost}:${parsed.owner}/${parsed.repo}.git`, + cloneUrlHttps: `https://${gitHost}/${parsed.owner}/${parsed.repo}.git`, + // For SSH URLs, subpath is only accepted via options.subpath effectiveSubpath: explicitSubpath, normalizedRepo: `${parsed.owner}/${parsed.repo}`, }; @@ -61,7 +63,7 @@ export function parseSkillRepoSource(input, options = {}) { // Shorthand: owner/repo[/subpath...] const shorthandMatch = raw.match(/^([^/\s]+)\/([^/\s]+)(?:\/(.+))?$/); if (shorthandMatch) { - const parsed = normalizeGitHubOwnerRepo(shorthandMatch[1], shorthandMatch[2]); + const parsed = normalizeGitOwnerRepo(shorthandMatch[1], shorthandMatch[2]); if (!parsed) { return { ok: false, error: { kind: 'invalidSource', message: 'Invalid repository source' } }; }