Initial commit: restructure to flat layout with ui/ and web/ at root

This commit is contained in:
2026-03-12 21:33:50 +08:00
commit decba25a08
1708 changed files with 199890 additions and 0 deletions

View File

@@ -0,0 +1,178 @@
# 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.
## 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.
- `clawdhub/`: ClawdHub registry integration.
- `index.js`: Public API exports for ClawdHub.
- `scan.js`: Scanning ClawdHub registry with pagination.
- `install.js`: Installation from ClawdHub (ZIP download).
- `api.js`: ClawdHub API client with rate limiting.
## Public API
The following functions are exported and used by the web server:
### Cache (`cache.js`)
- `getCacheKey({ normalizedRepo, subpath, identityId })`: Generate cache key for scan results.
- `getCachedScan(key)`: Retrieve cached scan result if not expired.
- `setCachedScan(key, value, ttlMs)`: Store scan result with TTL (default 30 minutes).
- `clearCache()`: Clear all cached scan results.
### Curated Sources (`curated-sources.js`)
- `getCuratedSkillsSources()`: Return list of curated skill sources (Anthropic, ClawdHub).
- `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.
### 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.
### 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.
### ClawdHub Integration (`clawdhub/index.js`)
- `isClawdHubSource(source)`: Check if source string refers to ClawdHub.
- `scanClawdHub()`: Scan entire ClawdHub registry for all skills (paginated, max 20 pages).
- `scanClawdHubPage({ cursor })`: Scan a single page of ClawdHub results with cursor-based pagination.
- `installSkillsFromClawdHub({ scope, targetSource, workingDirectory, userSkillDir, selections, conflictPolicy, conflictDecisions })`: Install skills from ClawdHub by downloading ZIP files.
- `fetchClawdHubSkills({ cursor })`: Fetch paginated skills list from ClawdHub API.
- `fetchClawdHubSkillVersion(slug, version)`: Fetch specific skill version details.
- `fetchClawdHubSkillInfo(slug)`: Fetch skill metadata without version details.
- `downloadClawdHubSkill(slug, version)`: Download skill package as ZIP buffer.
### ClawdHub Constants (`clawdhub/index.js`)
- `CLAWDHUB_SOURCE_ID`: Source identifier for curated sources.
- `CLAWDHUB_SOURCE_STRING`: Source string format.
## Internal Helpers
The following functions are internal helpers used by exported functions:
### Git Helpers (`git.js`)
- `runGit(args, options)`: Execute git command with optional SSH identity, timeout, and max buffer. Returns `{ ok, stdout, stderr, message, code, signal }`.
- `looksLikeAuthError(message)`: Detect if error message indicates authentication failure (permission denied, publickey, etc.).
- `assertGitAvailable()`: Check if git is available in PATH.
### Skill Name Validation (used in `install.js`, `scan.js`, `clawdhub/install.js`)
- `validateSkillName(skillName)`: Validate skill name against pattern `/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/` (1-64 chars, lowercase alphanumeric with hyphens).
### File System Helpers (`install.js`, `scan.js`, `clawdhub/install.js`)
- `safeRm(dir)`: Safely remove directory recursively (ignores errors).
- `ensureDir(dirPath)`: Ensure directory exists with recursive creation.
- `copyDirectoryNoSymlinks(srcDir, dstDir)`: Copy directory contents without symlinks, with path traversal protection.
- `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.
### SKILL.md Parsing (`scan.js`)
- `parseSkillMd(content)`: Parse YAML frontmatter from SKILL.md content. Returns `{ ok, frontmatter, warnings }`.
### Path Helpers (`install.js`)
- `toFsPath(repoDir, repoRelPosixPath)`: Convert POSIX path to filesystem path.
- `getTargetSkillDir({ scope, targetSource, workingDirectory, userSkillDir, skillName })`: Determine target installation directory based on scope (user/project), targetSource (opencode/agents), and skill name.
### ClawdHub API Helpers (`clawdhub/api.js`)
- `rateLimitedFetch(url, options)`: Fetch with rate limiting (120 req/min limit, 100ms delay between requests, exponential backoff on 429/500 errors).
- `mapClawdHubItem(item)`: Transform ClawdHub API response to SkillsCatalogItem format.
## Response Contracts
### Scan Skills Repository Response
- `ok`: Boolean indicating success.
- `normalizedRepo`: Normalized GitHub 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.
### Install Skills Response
- `ok`: Boolean indicating success.
- `installed`: Array of installed skills with `{ skillName, scope, source }`.
- `skipped`: Array of skipped skills with `{ skillName, reason }`.
- `error`: Error object with `{ kind, message, conflicts? }` on failure. Kinds: `authRequired`, `networkError`, `conflicts`, `invalidSource`, `unknown`.
### ClawdHub Scan Response
- `ok`: Boolean indicating success.
- `items`: Array of skill items with ClawdHub-specific metadata in `clawdhub` property.
- `nextCursor`: Pagination cursor for next page (only for `scanClawdHubPage`).
- `error`: Error object with `{ kind, message }` on failure.
### Parse Source Response
- `ok`: Boolean indicating success.
- `host`: GitHub host (`github.com`).
- `owner`: Repository owner.
- `repo`: Repository name.
- `cloneUrlSsh`: SSH clone URL.
- `cloneUrlHttps`: HTTPS clone URL.
- `effectiveSubpath`: Subpath for scanning (from source string or options).
- `normalizedRepo`: Normalized repo string (`owner/repo`).
- `error`: Error object with `{ kind, message }` on failure.
## Notes for Contributors
### Adding a New Skill Source
1. Create a new subdirectory under `packages/web/server/lib/skills-catalog/` (e.g., `newsource/`).
2. Implement `scan.js` with a function that returns `{ ok, items, error? }` matching the SkillsCatalogItem contract.
3. Implement `install.js` with a function that accepts selections and returns `{ ok, installed, skipped, error? }`.
4. Add the source to `CURATED_SKILLS_SOURCES` in `curated-sources.js` if it should appear in the default catalog.
5. Update `packages/web/server/index.js` to import and wire up the new source.
### 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.
- Invalid names result in non-installable skills with appropriate warnings.
### Git Cloning Strategy
- Use sparse checkout to minimize clone size: `sparse-checkout init`, `sparse-checkout set`, `checkout HEAD`.
- Preferred clone uses `--depth=1 --filter=blob:none` for partial clone with fallback to `--depth=1`.
- Always use non-interactive mode (`GIT_TERMINAL_PROMPT=0`) to avoid hangs.
- SSH keys are injected via `core.sshCommand` in git config.
### Conflict Resolution
- Installation checks for existing skills before downloading/cloning.
- Three conflict policies: `prompt`, `skipAll`, `overwriteAll`.
- Per-skill decisions override global policy via `conflictDecisions` map.
- Conflict response includes `{ skillName, scope, source }` for each conflict.
### ClawdHub Integration
- ClawdHub API base URL: `https://clawdhub.com/api/v1`.
- Pagination uses cursor-based approach with `MAX_PAGES=20` safety limit.
- Rate limiting: 120 req/min with 100ms delay between requests.
- Downloaded skills are extracted from ZIP files using `adm-zip`.
- Always validate `SKILL.md` exists before installation.
### Cache Management
- Cache keys include `normalizedRepo`, `subpath`, and `identityId` for isolation.
- Default TTL is 30 minutes; can be overridden via `ttlMs` parameter.
- Cache is in-memory (not persisted across restarts).
### Security Considerations
- Path traversal protection in `copyDirectoryNoSymlinks`: resolves real paths and checks containment.
- Symlinks are explicitly rejected to prevent escape from skill directory.
- SSH key paths are trimmed but not escaped in `git.js` (assumes safe input from profiles).
- Temporary directories are cleaned up in `finally` blocks.
### Error Handling
- All exported functions return `{ ok, ... }` result objects, not throw.
- Error kinds: `authRequired`, `networkError`, `conflicts`, `invalidSource`, `unknown`.
- Use `looksLikeAuthError` to detect SSH/HTTPS auth failures for better UX.
- Log errors to console for debugging but return structured errors to callers.
### Testing
- Run `bun run type-check`, `bun run lint`, and `bun run build` before finalizing changes.
- Consider edge cases: non-existent repos, private repos without auth, missing SKILL.md files, invalid skill names, conflicts, network failures.
## Verification Commands
- Type-check: `bun run type-check`
- Lint: `bun run lint`
- Build: `bun run build`

View File

@@ -0,0 +1,29 @@
const DEFAULT_TTL_MS = 30 * 60 * 1000;
const cache = new Map();
export function getCacheKey({ normalizedRepo, subpath, identityId }) {
const safeRepo = String(normalizedRepo || '').trim();
const safeSubpath = String(subpath || '').trim();
const safeIdentity = String(identityId || '').trim();
return `${safeRepo}::${safeSubpath}::${safeIdentity}`;
}
export function getCachedScan(key) {
const entry = cache.get(key);
if (!entry) return null;
if (Date.now() >= entry.expiresAt) {
cache.delete(key);
return null;
}
return entry.value;
}
export function setCachedScan(key, value, ttlMs = DEFAULT_TTL_MS) {
const ttl = Number.isFinite(ttlMs) ? ttlMs : DEFAULT_TTL_MS;
cache.set(key, { expiresAt: Date.now() + ttl, value });
}
export function clearCache() {
cache.clear();
}

View File

@@ -0,0 +1,158 @@
/**
* ClawdHub API client
*
* ClawdHub is a public skill registry at https://clawdhub.com
* This client provides methods to fetch skills list and download skill packages.
*/
const CLAWDHUB_API_BASE = 'https://clawdhub.com/api/v1';
const CLAWDHUB_PAGE_LIMIT = 25;
// Rate limiting: ClawdHub allows 120 requests/minute
const RATE_LIMIT_DELAY_MS = 100;
let lastRequestTime = 0;
async function rateLimitedFetch(url, options = {}) {
const maxAttempts = 10;
let lastResponse = null;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const now = Date.now();
const elapsed = now - lastRequestTime;
if (elapsed < RATE_LIMIT_DELAY_MS) {
await new Promise((resolve) => setTimeout(resolve, RATE_LIMIT_DELAY_MS - elapsed));
}
lastRequestTime = Date.now();
const response = await fetch(url, {
...options,
headers: {
Accept: 'application/json',
'User-Agent': 'OpenChamber/1.0',
...options.headers,
},
});
lastResponse = response;
if (response.status === 429 || response.status >= 500) {
if (attempt < maxAttempts - 1) {
const waitMs = 50 * (attempt + 1);
await new Promise((resolve) => setTimeout(resolve, waitMs));
continue;
}
}
return response;
}
return lastResponse;
}
/**
* Fetch paginated list of skills from ClawdHub
* @param {Object} options
* @param {string} [options.cursor] - Pagination cursor from previous response
* @returns {Promise<{ items: Array, nextCursor?: string }>}
*/
export async function fetchClawdHubSkills({ cursor } = {}) {
const url = cursor
? `${CLAWDHUB_API_BASE}/skills?cursor=${encodeURIComponent(cursor)}&limit=${CLAWDHUB_PAGE_LIMIT}`
: `${CLAWDHUB_API_BASE}/skills?limit=${CLAWDHUB_PAGE_LIMIT}`;
const response = await rateLimitedFetch(url);
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`ClawdHub API error (${response.status}): ${text || response.statusText}`);
}
const data = await response.json();
const nextCursor =
(typeof data.nextCursor === 'string' && data.nextCursor) ||
(typeof data.next_cursor === 'string' && data.next_cursor) ||
(typeof data.next === 'string' && data.next) ||
(typeof data.cursor === 'string' && data.cursor) ||
null;
return {
items: data.items || [],
nextCursor,
};
}
/**
* Fetch details for a specific skill version
* @param {string} slug - Skill slug/identifier
* @param {string} [version='latest'] - Version string or 'latest'
* @returns {Promise<{ skill: Object, version: Object }>}
*/
export async function fetchClawdHubSkillVersion(slug, version = 'latest') {
// For 'latest', we need to first get the skill metadata to find the latest version
if (version === 'latest') {
const skillResponse = await rateLimitedFetch(`${CLAWDHUB_API_BASE}/skills/${encodeURIComponent(slug)}`);
if (!skillResponse.ok) {
throw new Error(`ClawdHub skill not found: ${slug}`);
}
const skillData = await skillResponse.json();
const latestVersion = skillData.skill?.tags?.latest || skillData.latestVersion?.version;
if (!latestVersion) {
throw new Error(`No latest version found for skill: ${slug}`);
}
version = latestVersion;
}
const url = `${CLAWDHUB_API_BASE}/skills/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}`;
const response = await rateLimitedFetch(url);
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`ClawdHub version error (${response.status}): ${text || response.statusText}`);
}
return response.json();
}
/**
* Download a skill package as a ZIP buffer
* @param {string} slug - Skill slug/identifier
* @param {string} version - Specific version string
* @returns {Promise<ArrayBuffer>} - ZIP file contents
*/
export async function downloadClawdHubSkill(slug, version) {
const versionParam = typeof version === 'string' && version !== 'latest'
? `&version=${encodeURIComponent(version)}`
: '&tag=latest';
const url = `${CLAWDHUB_API_BASE}/download?slug=${encodeURIComponent(slug)}${versionParam}`;
const response = await rateLimitedFetch(url, {
headers: {
Accept: 'application/zip',
},
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`ClawdHub download error (${response.status}): ${text || response.statusText}`);
}
return response.arrayBuffer();
}
/**
* Get skill metadata without version details
* @param {string} slug - Skill slug/identifier
* @returns {Promise<Object>}
*/
export async function fetchClawdHubSkillInfo(slug) {
const url = `${CLAWDHUB_API_BASE}/skills/${encodeURIComponent(slug)}`;
const response = await rateLimitedFetch(url);
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(`ClawdHub skill error (${response.status}): ${text || response.statusText}`);
}
return response.json();
}

View File

@@ -0,0 +1,30 @@
/**
* ClawdHub integration module
*
* Provides skill browsing and installation from the ClawdHub registry.
* https://clawdhub.com
*/
export { scanClawdHub, scanClawdHubPage } from './scan.js';
export { installSkillsFromClawdHub } from './install.js';
export {
fetchClawdHubSkills,
fetchClawdHubSkillVersion,
fetchClawdHubSkillInfo,
downloadClawdHubSkill,
} from './api.js';
/**
* Check if a source string refers to ClawdHub
* @param {string} source
* @returns {boolean}
*/
export function isClawdHubSource(source) {
return typeof source === 'string' && source.startsWith('clawdhub:');
}
/**
* ClawdHub source identifier used in curated sources
*/
export const CLAWDHUB_SOURCE_ID = 'clawdhub';
export const CLAWDHUB_SOURCE_STRING = 'clawdhub:registry';

View File

@@ -0,0 +1,238 @@
/**
* ClawdHub skill installation
*
* Downloads skills from ClawdHub as ZIP files and extracts them
* to the appropriate skill directory.
*/
import fs from 'fs';
import os from 'os';
import path from 'path';
import AdmZip from 'adm-zip';
import { downloadClawdHubSkill, fetchClawdHubSkillInfo } from './api.js';
const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
function normalizeUserSkillDir(userSkillDir) {
if (!userSkillDir) return null;
const legacySkillDir = path.join(os.homedir(), '.config', 'opencode', 'skill');
const pluralSkillDir = path.join(os.homedir(), '.config', 'opencode', 'skills');
if (userSkillDir === legacySkillDir) {
if (fs.existsSync(legacySkillDir) && !fs.existsSync(pluralSkillDir)) return legacySkillDir;
return pluralSkillDir;
}
return userSkillDir;
}
function validateSkillName(skillName) {
if (typeof skillName !== 'string') return false;
if (skillName.length < 1 || skillName.length > 64) return false;
return SKILL_NAME_PATTERN.test(skillName);
}
async function safeRm(dir) {
try {
await fs.promises.rm(dir, { recursive: true, force: true });
} catch {
// ignore
}
}
async function ensureDir(dirPath) {
await fs.promises.mkdir(dirPath, { recursive: true });
}
function getTargetSkillDir({ scope, targetSource, workingDirectory, userSkillDir, skillName }) {
const source = targetSource === 'agents' ? 'agents' : 'opencode';
if (scope === 'user') {
if (source === 'agents') {
return path.join(os.homedir(), '.agents', 'skills', skillName);
}
return path.join(userSkillDir, skillName);
}
if (!workingDirectory) {
throw new Error('workingDirectory is required for project installs');
}
if (source === 'agents') {
return path.join(workingDirectory, '.agents', 'skills', skillName);
}
return path.join(workingDirectory, '.opencode', 'skills', skillName);
}
/**
* Install skills from ClawdHub registry
* @param {Object} options
* @param {string} options.scope - 'user' or 'project'
* @param {string} [options.targetSource] - 'opencode' or 'agents'
* @param {string} [options.workingDirectory] - Required for project scope
* @param {string} options.userSkillDir - User skills directory
* @param {Array} options.selections - Array of { skillDir, clawdhub: { slug, version } }
* @param {string} [options.conflictPolicy] - 'prompt', 'skipAll', or 'overwriteAll'
* @param {Object} [options.conflictDecisions] - Per-skill conflict decisions
* @returns {Promise<{ ok: boolean, installed?: Array, skipped?: Array, error?: Object }>}
*/
export async function installSkillsFromClawdHub({
scope,
targetSource,
workingDirectory,
userSkillDir,
selections,
conflictPolicy,
conflictDecisions,
} = {}) {
if (scope !== 'user' && scope !== 'project') {
return { ok: false, error: { kind: 'invalidSource', message: 'Invalid scope' } };
}
if (targetSource !== undefined && targetSource !== 'opencode' && targetSource !== 'agents') {
return { ok: false, error: { kind: 'invalidSource', message: 'Invalid target source' } };
}
if (!userSkillDir) {
return { ok: false, error: { kind: 'unknown', message: 'userSkillDir is required' } };
}
const normalizedUserSkillDir = normalizeUserSkillDir(userSkillDir);
if (normalizedUserSkillDir) {
userSkillDir = normalizedUserSkillDir;
}
if (scope === 'project' && !workingDirectory) {
return { ok: false, error: { kind: 'invalidSource', message: 'Project installs require a directory parameter' } };
}
const requestedSkills = Array.isArray(selections) ? selections : [];
if (requestedSkills.length === 0) {
return { ok: false, error: { kind: 'invalidSource', message: 'No skills selected for installation' } };
}
// Build installation plans
const skillPlans = requestedSkills.map((sel) => {
const slug = sel.clawdhub?.slug || sel.skillDir;
const version = sel.clawdhub?.version || 'latest';
return {
slug,
version,
installable: validateSkillName(slug),
};
});
// Check for conflicts before downloading
const conflicts = [];
for (const plan of skillPlans) {
if (!plan.installable) {
continue;
}
const targetDir = getTargetSkillDir({ scope, targetSource, workingDirectory, userSkillDir, skillName: plan.slug });
if (fs.existsSync(targetDir)) {
const decision = conflictDecisions?.[plan.slug];
const hasAutoPolicy = conflictPolicy === 'skipAll' || conflictPolicy === 'overwriteAll';
if (!decision && !hasAutoPolicy) {
conflicts.push({ skillName: plan.slug, scope, source: targetSource === 'agents' ? 'agents' : 'opencode' });
}
}
}
if (conflicts.length > 0) {
return {
ok: false,
error: {
kind: 'conflicts',
message: 'Some skills already exist in the selected scope',
conflicts,
},
};
}
const installed = [];
const skipped = [];
for (const plan of skillPlans) {
if (!plan.installable) {
skipped.push({ skillName: plan.slug, reason: 'Invalid skill name' });
continue;
}
try {
// Resolve 'latest' version if needed
let resolvedVersion = plan.version;
if (resolvedVersion === 'latest') {
try {
const info = await fetchClawdHubSkillInfo(plan.slug);
const latest = info.skill?.tags?.latest || info.latestVersion?.version || null;
if (latest) {
resolvedVersion = latest;
}
} catch {
// ignore
}
if (resolvedVersion === 'latest') {
skipped.push({ skillName: plan.slug, reason: 'Unable to resolve latest version' });
continue;
}
}
const targetDir = getTargetSkillDir({ scope, targetSource, workingDirectory, userSkillDir, skillName: plan.slug });
const exists = fs.existsSync(targetDir);
// Determine conflict resolution
let decision = conflictDecisions?.[plan.slug] || null;
if (!decision) {
if (exists && conflictPolicy === 'skipAll') decision = 'skip';
if (exists && conflictPolicy === 'overwriteAll') decision = 'overwrite';
if (!exists) decision = 'overwrite'; // No conflict, proceed
}
if (exists && decision === 'skip') {
skipped.push({ skillName: plan.slug, reason: 'Already installed (skipped)' });
continue;
}
if (exists && decision === 'overwrite') {
await safeRm(targetDir);
}
// Download the skill ZIP
const zipBuffer = await downloadClawdHubSkill(plan.slug, resolvedVersion);
// Extract to a temp directory first for validation
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), `clawdhub-${plan.slug}-`));
try {
const zip = new AdmZip(Buffer.from(zipBuffer));
zip.extractAllTo(tempDir, true);
// Verify SKILL.md exists
const skillMdPath = path.join(tempDir, 'SKILL.md');
if (!fs.existsSync(skillMdPath)) {
skipped.push({ skillName: plan.slug, reason: 'SKILL.md not found in downloaded package' });
continue;
}
// Move to target directory
await ensureDir(path.dirname(targetDir));
await fs.promises.rename(tempDir, targetDir);
installed.push({ skillName: plan.slug, scope, source: targetSource === 'agents' ? 'agents' : 'opencode' });
} catch (extractError) {
await safeRm(tempDir);
throw extractError;
}
} catch (error) {
console.error(`Failed to install ClawdHub skill "${plan.slug}":`, error);
skipped.push({
skillName: plan.slug,
reason: error instanceof Error ? error.message : 'Failed to download or extract skill',
});
}
}
return { ok: true, installed, skipped };
}

View File

@@ -0,0 +1,113 @@
/**
* ClawdHub skill scanning
*
* Fetches all available skills from the ClawdHub registry
* and transforms them into SkillsCatalogItem format.
*/
import { fetchClawdHubSkills } from './api.js';
const MAX_PAGES = 20; // Safety limit to prevent infinite loops
const CLAWDHUB_PAGE_LIMIT = 25;
const mapClawdHubItem = (item) => {
const latestVersion = item.tags?.latest || item.latestVersion?.version || '1.0.0';
return {
sourceId: 'clawdhub',
repoSource: 'clawdhub:registry',
repoSubpath: null,
gitIdentityId: null,
skillDir: item.slug,
skillName: item.slug,
frontmatterName: item.displayName || item.slug,
description: item.summary || null,
installable: true,
warnings: [],
// ClawdHub-specific metadata
clawdhub: {
slug: item.slug,
version: latestVersion,
displayName: item.displayName,
owner: item.owner?.handle || null,
downloads: item.stats?.downloads || 0,
stars: item.stats?.stars || 0,
versionsCount: item.stats?.versions || 1,
createdAt: item.createdAt,
updatedAt: item.updatedAt,
},
};
};
/**
* Scan ClawdHub registry for all available skills
* @returns {Promise<{ ok: boolean, items?: Array, error?: Object }>}
*/
export async function scanClawdHub() {
try {
const allItems = [];
let cursor = null;
for (let page = 0; page < MAX_PAGES; page++) {
let items = [];
let nextCursor = null;
try {
const pageResult = await fetchClawdHubSkills({ cursor });
items = pageResult.items || [];
nextCursor = pageResult.nextCursor || null;
} catch (error) {
if (page > 0 && allItems.length > 0) {
console.warn('ClawdHub pagination failed; returning partial results.');
break;
}
throw error;
}
for (const item of items) {
allItems.push(mapClawdHubItem(item));
}
if (!nextCursor) {
break;
}
cursor = nextCursor;
}
// Sort by downloads (most popular first)
allItems.sort((a, b) => (b.clawdhub?.downloads || 0) - (a.clawdhub?.downloads || 0));
return { ok: true, items: allItems };
} catch (error) {
console.error('ClawdHub scan error:', error);
return {
ok: false,
error: {
kind: 'networkError',
message: error instanceof Error ? error.message : 'Failed to fetch skills from ClawdHub',
},
};
}
}
/**
* Scan a single ClawdHub page (cursor-based)
* @returns {Promise<{ ok: boolean, items?: Array, nextCursor?: string | null, error?: Object }>}
*/
export async function scanClawdHubPage({ cursor } = {}) {
try {
const { items, nextCursor } = await fetchClawdHubSkills({ cursor });
const mapped = (items || []).map(mapClawdHubItem).slice(0, CLAWDHUB_PAGE_LIMIT);
mapped.sort((a, b) => (b.clawdhub?.downloads || 0) - (a.clawdhub?.downloads || 0));
return { ok: true, items: mapped, nextCursor: nextCursor || null };
} catch (error) {
console.error('ClawdHub page scan error:', error);
return {
ok: false,
error: {
kind: 'networkError',
message: error instanceof Error ? error.message : 'Failed to fetch skills from ClawdHub',
},
};
}
}

View File

@@ -0,0 +1,21 @@
export const CURATED_SKILLS_SOURCES = [
{
id: 'anthropic',
label: 'Anthropic',
description: "Anthropic's public skills repository",
source: 'anthropics/skills',
defaultSubpath: 'skills',
sourceType: 'github',
},
{
id: 'clawdhub',
label: 'ClawdHub',
description: 'Community skill registry with vector search',
source: 'clawdhub:registry',
sourceType: 'clawdhub',
},
];
export function getCuratedSkillsSources() {
return CURATED_SKILLS_SOURCES.slice();
}

View File

@@ -0,0 +1,76 @@
import { execFile } from 'child_process';
import { promisify } from 'util';
const execFileAsync = promisify(execFile);
const DEFAULT_TIMEOUT_MS = 60_000;
const DEFAULT_MAX_BUFFER = 4 * 1024 * 1024;
export function looksLikeAuthError(message) {
const text = String(message || '');
return (
/permission denied/i.test(text) ||
/publickey/i.test(text) ||
/could not read from remote repository/i.test(text) ||
/authentication failed/i.test(text) ||
/fatal: could not/i.test(text)
);
}
export async function runGit(args, options = {}) {
const cwd = options.cwd;
const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : DEFAULT_TIMEOUT_MS;
const maxBuffer = Number.isFinite(options.maxBuffer) ? options.maxBuffer : DEFAULT_MAX_BUFFER;
const identity = options.identity || null;
const normalizedArgs = Array.isArray(args) ? args.slice() : [];
// Non-interactive git (avoid prompts / hangs)
const env = {
...process.env,
GIT_TERMINAL_PROMPT: '0',
};
if (identity?.sshKey) {
const sshKeyPath = String(identity.sshKey).trim();
if (sshKeyPath) {
// Avoid interactive host key prompts; still safe against changed keys.
const sshCommand = `ssh -i ${sshKeyPath} -o BatchMode=yes -o StrictHostKeyChecking=accept-new`;
normalizedArgs.unshift(`core.sshCommand=${sshCommand}`);
normalizedArgs.unshift('-c');
}
}
try {
const { stdout, stderr } = await execFileAsync('git', normalizedArgs, {
cwd,
env,
timeout: timeoutMs,
maxBuffer,
});
return { ok: true, stdout: stdout || '', stderr: stderr || '' };
} catch (error) {
const err = error;
const stdout = typeof err?.stdout === 'string' ? err.stdout : '';
const stderr = typeof err?.stderr === 'string' ? err.stderr : '';
const message = err instanceof Error ? err.message : String(err);
return {
ok: false,
stdout,
stderr,
message,
code: typeof err?.code === 'number' ? err.code : null,
signal: typeof err?.signal === 'string' ? err.signal : null,
};
}
}
export async function assertGitAvailable() {
const result = await runGit(['--version'], { timeoutMs: 5_000 });
if (!result.ok) {
return { ok: false, error: { kind: 'gitUnavailable', message: 'Git is not available in PATH' } };
}
return { ok: true };
}

View File

@@ -0,0 +1,42 @@
/**
* Skills catalog module
*
* Provides skill scanning, installation, and caching from GitHub repositories and ClawdHub.
*/
export {
CURATED_SKILLS_SOURCES,
getCuratedSkillsSources,
} from './curated-sources.js';
export {
getCacheKey,
getCachedScan,
setCachedScan,
clearCache,
} from './cache.js';
export {
parseSkillRepoSource,
} from './source.js';
export {
scanSkillsRepository,
} from './scan.js';
export {
installSkillsFromRepository,
} from './install.js';
export {
scanClawdHub,
scanClawdHubPage,
installSkillsFromClawdHub,
fetchClawdHubSkills,
fetchClawdHubSkillVersion,
fetchClawdHubSkillInfo,
downloadClawdHubSkill,
isClawdHubSource,
CLAWDHUB_SOURCE_ID,
CLAWDHUB_SOURCE_STRING,
} from './clawdhub/index.js';

View File

@@ -0,0 +1,294 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { assertGitAvailable, looksLikeAuthError, runGit } from './git.js';
import { parseSkillRepoSource } from './source.js';
const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
function normalizeUserSkillDir(userSkillDir) {
if (!userSkillDir) return null;
const legacySkillDir = path.join(os.homedir(), '.config', 'opencode', 'skill');
const pluralSkillDir = path.join(os.homedir(), '.config', 'opencode', 'skills');
if (userSkillDir === legacySkillDir) {
if (fs.existsSync(legacySkillDir) && !fs.existsSync(pluralSkillDir)) return legacySkillDir;
return pluralSkillDir;
}
return userSkillDir;
}
function validateSkillName(skillName) {
if (typeof skillName !== 'string') return false;
if (skillName.length < 1 || skillName.length > 64) return false;
return SKILL_NAME_PATTERN.test(skillName);
}
async function safeRm(dir) {
try {
await fs.promises.rm(dir, { recursive: true, force: true });
} catch {
// ignore
}
}
function toFsPath(repoDir, repoRelPosixPath) {
const parts = String(repoRelPosixPath || '')
.split('/')
.map((p) => p.trim())
.filter(Boolean);
return path.join(repoDir, ...parts);
}
async function ensureDir(dirPath) {
await fs.promises.mkdir(dirPath, { recursive: true });
}
async function copyDirectoryNoSymlinks(srcDir, dstDir) {
const srcReal = await fs.promises.realpath(srcDir);
await ensureDir(dstDir);
const walk = async (currentSrc, currentDst) => {
const entries = await fs.promises.readdir(currentSrc, { withFileTypes: true });
for (const entry of entries) {
const nextSrc = path.join(currentSrc, entry.name);
const nextDst = path.join(currentDst, entry.name);
const stat = await fs.promises.lstat(nextSrc);
if (stat.isSymbolicLink()) {
throw new Error('Symlinks are not supported in skills');
}
// Guard against traversal: ensure source is still under srcReal
const nextRealParent = await fs.promises.realpath(path.dirname(nextSrc));
if (!nextRealParent.startsWith(srcReal)) {
throw new Error('Invalid source path traversal detected');
}
if (stat.isDirectory()) {
await ensureDir(nextDst);
await walk(nextSrc, nextDst);
continue;
}
if (stat.isFile()) {
await ensureDir(path.dirname(nextDst));
await fs.promises.copyFile(nextSrc, nextDst);
try {
await fs.promises.chmod(nextDst, stat.mode & 0o777);
} catch {
// best-effort
}
continue;
}
// Skip other types (sockets, devices, etc.)
}
};
await walk(srcDir, dstDir);
}
async function cloneRepo({ cloneUrl, identity, tempDir }) {
const preferred = ['clone', '--depth', '1', '--filter=blob:none', '--no-checkout', cloneUrl, tempDir];
const fallback = ['clone', '--depth', '1', '--no-checkout', cloneUrl, tempDir];
const result = await runGit(preferred, { identity, timeoutMs: 90_000 });
if (result.ok) return { ok: true };
const fallbackResult = await runGit(fallback, { identity, timeoutMs: 90_000 });
if (fallbackResult.ok) return { ok: true };
return {
ok: false,
error: fallbackResult,
};
}
function getTargetSkillDir({ scope, targetSource, workingDirectory, userSkillDir, skillName }) {
const source = targetSource === 'agents' ? 'agents' : 'opencode';
if (scope === 'user') {
if (source === 'agents') {
return path.join(os.homedir(), '.agents', 'skills', skillName);
}
return path.join(userSkillDir, skillName);
}
if (!workingDirectory) {
throw new Error('workingDirectory is required for project installs');
}
if (source === 'agents') {
return path.join(workingDirectory, '.agents', 'skills', skillName);
}
return path.join(workingDirectory, '.opencode', 'skills', skillName);
}
export async function installSkillsFromRepository({
source,
subpath,
defaultSubpath,
identity,
scope,
targetSource,
workingDirectory,
userSkillDir,
selections,
conflictPolicy,
conflictDecisions,
} = {}) {
const gitCheck = await assertGitAvailable();
if (!gitCheck.ok) {
return { ok: false, error: gitCheck.error };
}
const normalizedUserSkillDir = normalizeUserSkillDir(userSkillDir);
if (normalizedUserSkillDir) {
userSkillDir = normalizedUserSkillDir;
}
if (!userSkillDir) {
return { ok: false, error: { kind: 'unknown', message: 'userSkillDir is required' } };
}
if (scope !== 'user' && scope !== 'project') {
return { ok: false, error: { kind: 'invalidSource', message: 'Invalid scope' } };
}
if (targetSource !== undefined && targetSource !== 'opencode' && targetSource !== 'agents') {
return { ok: false, error: { kind: 'invalidSource', message: 'Invalid target source' } };
}
if (scope === 'project' && !workingDirectory) {
return { ok: false, error: { kind: 'invalidSource', message: 'Project installs require a directory parameter' } };
}
const parsed = parseSkillRepoSource(source, { subpath });
if (!parsed.ok) {
return { ok: false, error: parsed.error };
}
const effectiveSubpath = parsed.effectiveSubpath || (typeof defaultSubpath === 'string' && defaultSubpath.trim() ? defaultSubpath.trim() : null);
void effectiveSubpath;
const cloneUrl = identity?.sshKey ? parsed.cloneUrlSsh : parsed.cloneUrlHttps;
const requestedDirs = Array.isArray(selections) ? selections.map((s) => String(s?.skillDir || '').trim()).filter(Boolean) : [];
if (requestedDirs.length === 0) {
return { ok: false, error: { kind: 'invalidSource', message: 'No skills selected for installation' } };
}
// Validate names early and compute conflicts without mutating.
const skillPlans = requestedDirs.map((skillDirPosix) => {
const skillName = path.posix.basename(skillDirPosix);
return { skillDirPosix, skillName, installable: validateSkillName(skillName) };
});
const conflicts = [];
for (const plan of skillPlans) {
if (!plan.installable) {
continue;
}
const targetDir = getTargetSkillDir({ scope, targetSource, workingDirectory, userSkillDir, skillName: plan.skillName });
if (fs.existsSync(targetDir)) {
const decision = conflictDecisions?.[plan.skillName];
const hasAutoPolicy = conflictPolicy === 'skipAll' || conflictPolicy === 'overwriteAll';
if (!decision && !hasAutoPolicy) {
conflicts.push({ skillName: plan.skillName, scope, source: targetSource === 'agents' ? 'agents' : 'opencode' });
}
}
}
if (conflicts.length > 0) {
return {
ok: false,
error: {
kind: 'conflicts',
message: 'Some skills already exist in the selected scope',
conflicts,
},
};
}
const tempBase = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'openchamber-skills-install-'));
try {
const cloned = await cloneRepo({ cloneUrl, identity, tempDir: tempBase });
if (!cloned.ok) {
const msg = `${cloned.error?.stderr || ''}\n${cloned.error?.message || ''}`.trim();
if (looksLikeAuthError(msg)) {
return { ok: false, error: { kind: 'authRequired', message: 'Authentication required to access this repository', sshOnly: true } };
}
return { ok: false, error: { kind: 'networkError', message: msg || 'Failed to clone repository' } };
}
// Selective checkout for only requested skill dirs.
await runGit(['-C', tempBase, 'sparse-checkout', 'init', '--cone'], { identity, timeoutMs: 15_000 });
const setResult = await runGit(['-C', tempBase, 'sparse-checkout', 'set', ...requestedDirs], { identity, timeoutMs: 30_000 });
if (!setResult.ok) {
return { ok: false, error: { kind: 'unknown', message: setResult.stderr || setResult.message || 'Failed to configure sparse checkout' } };
}
const checkoutResult = await runGit(['-C', tempBase, 'checkout', '--force', 'HEAD'], { identity, timeoutMs: 60_000 });
if (!checkoutResult.ok) {
return { ok: false, error: { kind: 'unknown', message: checkoutResult.stderr || checkoutResult.message || 'Failed to checkout repository' } };
}
const installed = [];
const skipped = [];
for (const plan of skillPlans) {
if (!plan.installable) {
skipped.push({ skillName: plan.skillName, reason: 'Invalid skill name (directory basename)' });
continue;
}
const srcDir = toFsPath(tempBase, plan.skillDirPosix);
const skillMdPath = path.join(srcDir, 'SKILL.md');
if (!fs.existsSync(skillMdPath)) {
skipped.push({ skillName: plan.skillName, reason: 'SKILL.md not found in selected directory' });
continue;
}
const targetDir = getTargetSkillDir({ scope, targetSource, workingDirectory, userSkillDir, skillName: plan.skillName });
const exists = fs.existsSync(targetDir);
let decision = conflictDecisions?.[plan.skillName] || null;
if (!decision) {
if (exists && conflictPolicy === 'skipAll') decision = 'skip';
if (exists && conflictPolicy === 'overwriteAll') decision = 'overwrite';
if (!exists) decision = 'overwrite'; // no conflict, proceed
}
if (exists && decision === 'skip') {
skipped.push({ skillName: plan.skillName, reason: 'Already installed (skipped)' });
continue;
}
if (exists && decision === 'overwrite') {
await safeRm(targetDir);
}
// Ensure project parent directories exist
await ensureDir(path.dirname(targetDir));
try {
await copyDirectoryNoSymlinks(srcDir, targetDir);
installed.push({ skillName: plan.skillName, scope, source: targetSource === 'agents' ? 'agents' : 'opencode' });
} catch (error) {
await safeRm(targetDir);
skipped.push({
skillName: plan.skillName,
reason: error instanceof Error ? error.message : 'Failed to copy skill files',
});
}
}
return { ok: true, installed, skipped };
} finally {
await safeRm(tempBase);
}
}

View File

@@ -0,0 +1,221 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import yaml from 'yaml';
import { assertGitAvailable, looksLikeAuthError, runGit } from './git.js';
import { parseSkillRepoSource } from './source.js';
const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
function validateSkillName(skillName) {
if (typeof skillName !== 'string') return false;
if (skillName.length < 1 || skillName.length > 64) return false;
return SKILL_NAME_PATTERN.test(skillName);
}
function parseSkillMd(content) {
const text = typeof content === 'string' ? content : '';
const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
if (!match) {
return {
ok: true,
frontmatter: {},
warnings: ['Invalid SKILL.md: missing YAML frontmatter delimiter'],
};
}
try {
const frontmatter = yaml.parse(match[1]) || {};
return { ok: true, frontmatter, warnings: [] };
} catch {
return {
ok: true,
frontmatter: {},
warnings: ['Invalid SKILL.md: failed to parse YAML frontmatter'],
};
}
}
async function safeRm(dir) {
try {
await fs.promises.rm(dir, { recursive: true, force: true });
} catch {
// ignore
}
}
async function cloneRepo({ cloneUrl, identity, tempDir }) {
const preferred = ['clone', '--depth', '1', '--filter=blob:none', '--no-checkout', cloneUrl, tempDir];
const fallback = ['clone', '--depth', '1', '--no-checkout', cloneUrl, tempDir];
const result = await runGit(preferred, { identity, timeoutMs: 60_000 });
if (result.ok) return { ok: true };
const fallbackResult = await runGit(fallback, { identity, timeoutMs: 60_000 });
if (fallbackResult.ok) return { ok: true };
return {
ok: false,
error: fallbackResult,
};
}
export async function scanSkillsRepository({
source,
subpath,
defaultSubpath,
identity,
} = {}) {
const gitCheck = await assertGitAvailable();
if (!gitCheck.ok) {
return { ok: false, error: gitCheck.error };
}
const parsed = parseSkillRepoSource(source, { subpath });
if (!parsed.ok) {
return { ok: false, error: parsed.error };
}
const effectiveSubpath = parsed.effectiveSubpath || (typeof defaultSubpath === 'string' && defaultSubpath.trim() ? defaultSubpath.trim() : null);
const cloneUrl = identity?.sshKey ? parsed.cloneUrlSsh : parsed.cloneUrlHttps;
const tempBase = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'openchamber-skills-scan-'));
try {
const cloned = await cloneRepo({ cloneUrl, identity, tempDir: tempBase });
if (!cloned.ok) {
const msg = `${cloned.error?.stderr || ''}\n${cloned.error?.message || ''}`.trim();
if (looksLikeAuthError(msg)) {
return { ok: false, error: { kind: 'authRequired', message: 'Authentication required to access this repository', sshOnly: true } };
}
return { ok: false, error: { kind: 'networkError', message: msg || 'Failed to clone repository' } };
}
const toFsPath = (posixPath) => path.join(tempBase, ...String(posixPath || '').split('/').filter(Boolean));
const patterns = effectiveSubpath
? [`${effectiveSubpath}/SKILL.md`, `${effectiveSubpath}/**/SKILL.md`]
: ['SKILL.md', '**/SKILL.md'];
let skillMdPaths = null;
// Fast path: sparse checkout only SKILL.md files, then parse from disk.
// This avoids one `git show` per skill.
const sparseInit = await runGit(['-C', tempBase, 'sparse-checkout', 'init', '--no-cone'], { identity, timeoutMs: 15_000 });
if (sparseInit.ok) {
const sparseSet = await runGit(['-C', tempBase, 'sparse-checkout', 'set', ...patterns], { identity, timeoutMs: 30_000 });
if (sparseSet.ok) {
const checkout = await runGit(['-C', tempBase, 'checkout', '--force', 'HEAD'], { identity, timeoutMs: 60_000 });
if (checkout.ok) {
const lsFiles = await runGit(['-C', tempBase, 'ls-files'], { identity, timeoutMs: 15_000 });
if (lsFiles.ok) {
skillMdPaths = lsFiles.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.filter((p) => p.endsWith('/SKILL.md') || p === 'SKILL.md');
}
}
}
}
// Fallback: list tree and read SKILL.md blobs via git.
if (!Array.isArray(skillMdPaths)) {
const listArgs = ['-C', tempBase, 'ls-tree', '-r', '--name-only', 'HEAD'];
if (effectiveSubpath) {
listArgs.push('--', effectiveSubpath);
}
const listResult = await runGit(listArgs, { identity, timeoutMs: 30_000 });
if (!listResult.ok) {
// If subpath doesn't exist, treat as empty scan.
return {
ok: true,
normalizedRepo: parsed.normalizedRepo,
effectiveSubpath,
items: [],
};
}
skillMdPaths = listResult.stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.filter((p) => p.endsWith('/SKILL.md') || p === 'SKILL.md');
}
// Root-level SKILL.md doesn't map cleanly to OpenCode's "skill name == folder name" convention.
const uniqueSkillDirs = Array.from(
new Set(
skillMdPaths
.filter((p) => p !== 'SKILL.md')
.map((p) => path.posix.dirname(p))
)
);
const items = [];
const maxParallel = 10;
let idx = 0;
const worker = async () => {
while (idx < uniqueSkillDirs.length) {
const skillDir = uniqueSkillDirs[idx++];
const skillName = path.posix.basename(skillDir);
const skillMdPath = path.posix.join(skillDir, 'SKILL.md');
const warnings = [];
let skillMdContent = '';
// Prefer filesystem reads when sparse checkout succeeded.
const filePath = toFsPath(skillMdPath);
try {
skillMdContent = await fs.promises.readFile(filePath, 'utf8');
} catch {
const showResult = await runGit(['-C', tempBase, 'show', `HEAD:${skillMdPath}`], { identity, timeoutMs: 15_000 });
if (!showResult.ok) {
warnings.push('Failed to read SKILL.md');
} else {
skillMdContent = showResult.stdout;
}
}
const parsedMd = parseSkillMd(skillMdContent);
warnings.push(...(parsedMd.warnings || []));
const description = typeof parsedMd.frontmatter?.description === 'string' ? parsedMd.frontmatter.description : undefined;
const frontmatterName = typeof parsedMd.frontmatter?.name === 'string' ? parsedMd.frontmatter.name : undefined;
const installable = validateSkillName(skillName);
if (!installable) {
warnings.push('Skill directory name is not a valid OpenCode skill name');
}
items.push({
repoSource: source,
repoSubpath: effectiveSubpath || undefined,
skillDir,
skillName,
frontmatterName,
description,
installable,
warnings: warnings.length ? warnings : undefined,
});
}
};
await Promise.all(Array.from({ length: Math.min(maxParallel, uniqueSkillDirs.length || 1) }, () => worker()));
// Stable ordering for UX
items.sort((a, b) => a.skillName.localeCompare(b.skillName));
return {
ok: true,
normalizedRepo: parsed.normalizedRepo,
effectiveSubpath,
items,
};
} finally {
await safeRm(tempBase);
}
}

View File

@@ -0,0 +1,85 @@
const GITHUB_HOST = 'github.com';
function normalizeGitHubOwnerRepo(owner, repo) {
const normalizedOwner = String(owner || '').trim();
const normalizedRepo = String(repo || '').trim().replace(/\.git$/i, '');
if (!normalizedOwner || !normalizedRepo) {
return null;
}
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;
// 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}`,
};
}
// 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]);
if (!parsed) {
return { ok: false, error: { kind: 'invalidSource', message: 'Invalid HTTPS 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`,
effectiveSubpath: explicitSubpath,
normalizedRepo: `${parsed.owner}/${parsed.repo}`,
};
}
// Shorthand: owner/repo[/subpath...]
const shorthandMatch = raw.match(/^([^/\s]+)\/([^/\s]+)(?:\/(.+))?$/);
if (shorthandMatch) {
const parsed = normalizeGitHubOwnerRepo(shorthandMatch[1], shorthandMatch[2]);
if (!parsed) {
return { ok: false, error: { kind: 'invalidSource', message: 'Invalid repository source' } };
}
const shorthandSubpath = typeof shorthandMatch[3] === 'string' && shorthandMatch[3].trim() ? shorthandMatch[3].trim() : null;
const effectiveSubpath = explicitSubpath || shorthandSubpath;
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`,
effectiveSubpath,
normalizedRepo: `${parsed.owner}/${parsed.repo}`,
};
}
return { ok: false, error: { kind: 'invalidSource', message: 'Unsupported repository source format' } };
}