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 <artac@artacs-MacBook-Pro.local>
This commit is contained in:
committed by
GitHub
co-authored by
artac
parent
51c4ec693b
commit
f059bc8dd6
@@ -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) {
|
||||
|
||||
@@ -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]+)(?:\/(.+))?$/);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' } };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user