* feat(skills): remove ClawHub catalog integration Drop the ClawHub registry as a skills catalog source across web server, shared UI, VS Code, docs, and locales. The catalog now serves git-based sources only: the curated Anthropic repo and user-defined repositories. Also removes the now-unused adm-zip dependency. * feat(skills): redesign catalog around curated GitHub repositories Replace the single-source dropdown with a card grid of curated GitHub repositories (Anthropic, OpenAI, Cursor pstack/skills, Matt Pocock) plus user-defined sources. Source cards show skill counts, GitHub stars, and last-updated time; a global search covers all loaded sources. Server: curated sources gain GitHub repo metadata (stars, pushed_at) fetched best-effort with a 3-hour in-memory and on-disk cache; scans run through a concurrency-limited, deduplicated cache with 3-hour TTL persisted across restarts. Refresh still bypasses the cache. Shared UI: source cards, global search with clear button, per-skill GitHub links, install/installed states. VS Code curated list updated to match. All new copy translated across 12 locales. * fix(skills): address catalog review findings - GitHub metadata fetch timeout drops to 1.5s (under the catalog client's 3s deadline) and failed lookups cache briefly (5 min) so repeated catalog loads do not re-hit a failing API. - Disk cache files are written with owner-only permissions (0o600); rename preserves the mode. - loadSource deduplicates concurrent in-flight requests per source and the shared isLoadingSource flag now clears only when the last active source load finishes.
632 lines
22 KiB
TypeScript
632 lines
22 KiB
TypeScript
import fs from 'fs';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
import { execFile } from 'child_process';
|
|
import { promisify } from 'util';
|
|
import yaml from 'yaml';
|
|
|
|
import { discoverSkills } from './opencodeConfig';
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
const DEFAULT_MAX_BUFFER = 4 * 1024 * 1024;
|
|
|
|
const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
|
|
|
|
type SkillScope = 'user' | 'project';
|
|
type SkillInstallSource = 'opencode' | 'agents';
|
|
|
|
export type SkillsCatalogSourceConfig = {
|
|
id: string;
|
|
label: string;
|
|
description?: string;
|
|
source: string;
|
|
defaultSubpath?: string;
|
|
};
|
|
|
|
type CuratedSource = SkillsCatalogSourceConfig;
|
|
|
|
type SkillFrontmatter = {
|
|
name?: unknown;
|
|
description?: unknown;
|
|
[key: string]: unknown;
|
|
};
|
|
|
|
type SkillsCatalogItem = {
|
|
repoSource: string;
|
|
repoSubpath?: string;
|
|
skillDir: string;
|
|
skillName: string;
|
|
frontmatterName?: string;
|
|
description?: string;
|
|
installable: boolean;
|
|
warnings?: string[];
|
|
};
|
|
type SkillsCatalogItemWithBadge = SkillsCatalogItem & {
|
|
sourceId: string;
|
|
installed: { isInstalled: boolean; scope?: SkillScope; source?: SkillInstallSource };
|
|
};
|
|
|
|
type SkillsRepoError =
|
|
| { kind: 'authRequired'; message: string; sshOnly: boolean }
|
|
| { kind: 'invalidSource'; message: string }
|
|
| { kind: 'gitUnavailable'; message: string }
|
|
| { kind: 'networkError'; message: string }
|
|
| { kind: 'unknown'; message: string }
|
|
| { kind: 'conflicts'; message: string; conflicts: Array<{ skillName: string; scope: SkillScope; source?: SkillInstallSource }> };
|
|
|
|
type SkillsRepoScanResult =
|
|
| { ok: true; items: SkillsCatalogItem[] }
|
|
| { ok: false; error: SkillsRepoError };
|
|
|
|
type SkillsInstallResult =
|
|
| { ok: true; installed: Array<{ skillName: string; scope: SkillScope; source?: SkillInstallSource }>; skipped: Array<{ skillName: string; reason: string }> }
|
|
| { ok: false; error: SkillsRepoError };
|
|
|
|
const CURATED_SOURCES: CuratedSource[] = [
|
|
{
|
|
id: 'anthropic',
|
|
label: 'Anthropic',
|
|
description: "Anthropic's public skills repository",
|
|
source: 'anthropics/skills',
|
|
defaultSubpath: 'skills',
|
|
},
|
|
{
|
|
id: 'openai',
|
|
label: 'OpenAI',
|
|
description: "OpenAI's curated skills",
|
|
source: 'openai/skills',
|
|
defaultSubpath: 'skills/.curated',
|
|
},
|
|
{
|
|
id: 'cursor',
|
|
label: 'Cursor',
|
|
description: "Cursor's plugin skills",
|
|
source: 'cursor/plugins',
|
|
defaultSubpath: 'pstack/skills',
|
|
},
|
|
{
|
|
id: 'mattpocock',
|
|
label: 'Matt Pocock',
|
|
description: 'Matt Pocock skills collection',
|
|
source: 'mattpocock/skills',
|
|
},
|
|
];
|
|
|
|
function validateSkillName(skillName: string): boolean {
|
|
if (skillName.length < 1 || skillName.length > 64) return false;
|
|
return SKILL_NAME_PATTERN.test(skillName);
|
|
}
|
|
|
|
function looksLikeAuthError(message: string): boolean {
|
|
return (
|
|
/permission denied/i.test(message) ||
|
|
/publickey/i.test(message) ||
|
|
/could not read from remote repository/i.test(message) ||
|
|
/authentication failed/i.test(message)
|
|
);
|
|
}
|
|
|
|
async function runGit(args: string[], options?: { cwd?: string; timeoutMs?: number }) {
|
|
try {
|
|
const { stdout, stderr } = await execFileAsync('git', args, {
|
|
cwd: options?.cwd,
|
|
timeout: options?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
maxBuffer: DEFAULT_MAX_BUFFER,
|
|
env: {
|
|
...process.env,
|
|
GIT_TERMINAL_PROMPT: '0',
|
|
},
|
|
});
|
|
return { ok: true as const, stdout: stdout || '', stderr: stderr || '' };
|
|
} catch (error) {
|
|
const err = error as { stdout?: string; stderr?: string; message?: string };
|
|
return {
|
|
ok: false as const,
|
|
stdout: typeof err.stdout === 'string' ? err.stdout : '',
|
|
stderr: typeof err.stderr === 'string' ? err.stderr : '',
|
|
message: typeof err.message === 'string' ? err.message : 'Git command failed',
|
|
};
|
|
}
|
|
}
|
|
|
|
async function assertGitAvailable() {
|
|
const result = await runGit(['--version'], { timeoutMs: 5_000 });
|
|
if (!result.ok) {
|
|
return { ok: false as const, error: { kind: 'gitUnavailable' as const, message: 'Git is not available in PATH' } };
|
|
}
|
|
return { ok: true as const };
|
|
}
|
|
|
|
function parseSkillRepoSource(input: string, subpath?: string) {
|
|
const raw = (input || '').trim();
|
|
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;
|
|
|
|
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.` } };
|
|
}
|
|
|
|
return {
|
|
ok: true as const,
|
|
normalizedRepo: `${owner}/${repo}`,
|
|
cloneUrlHttps: `https://${gitHost}/${owner}/${repo}.git`,
|
|
cloneUrlSsh: `git@${gitHost}:${owner}/${repo}.git`,
|
|
effectiveSubpath: explicitSubpath,
|
|
}
|
|
}
|
|
|
|
const shorthandMatch = raw.match(/^([^/\s]+)\/([^/\s]+)(?:\/(.+))?$/);
|
|
if (shorthandMatch) {
|
|
const owner = shorthandMatch[1];
|
|
const repo = shorthandMatch[2].replace(/\.git$/i, '');
|
|
const shorthandSubpath = shorthandMatch[3]?.trim() || null;
|
|
return {
|
|
ok: true as const,
|
|
normalizedRepo: `${owner}/${repo}`,
|
|
cloneUrlHttps: `https://github.com/${owner}/${repo}.git`,
|
|
cloneUrlSsh: `git@github.com:${owner}/${repo}.git`,
|
|
effectiveSubpath: explicitSubpath || shorthandSubpath,
|
|
};
|
|
}
|
|
|
|
return { ok: false as const, error: { kind: 'invalidSource' as const, message: 'Unsupported repository source format' } };
|
|
}
|
|
|
|
function parseSkillMd(content: string): { frontmatter: SkillFrontmatter; warnings: string[] } {
|
|
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
|
if (!match) {
|
|
return {
|
|
frontmatter: {},
|
|
warnings: ['Invalid SKILL.md: missing YAML frontmatter delimiter'],
|
|
};
|
|
}
|
|
|
|
try {
|
|
const parsed = yaml.parse(match[1]);
|
|
const frontmatter = parsed && typeof parsed === 'object' ? (parsed as SkillFrontmatter) : {};
|
|
return { frontmatter, warnings: [] };
|
|
} catch {
|
|
return {
|
|
frontmatter: {},
|
|
warnings: ['Invalid SKILL.md: failed to parse YAML frontmatter'],
|
|
};
|
|
}
|
|
}
|
|
|
|
async function safeRm(dir: string) {
|
|
try {
|
|
await fs.promises.rm(dir, { recursive: true, force: true });
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
async function cloneRepo(cloneUrl: string, targetDir: string) {
|
|
const preferred = ['clone', '--depth', '1', '--filter=blob:none', '--no-checkout', cloneUrl, targetDir];
|
|
const fallback = ['clone', '--depth', '1', '--no-checkout', cloneUrl, targetDir];
|
|
|
|
const result = await runGit(preferred, { timeoutMs: 60_000 });
|
|
if (result.ok) return { ok: true as const };
|
|
|
|
const fallbackResult = await runGit(fallback, { timeoutMs: 60_000 });
|
|
if (fallbackResult.ok) return { ok: true as const };
|
|
|
|
const combined = `${fallbackResult.stderr}\n${fallbackResult.message}`.trim();
|
|
if (looksLikeAuthError(combined)) {
|
|
return {
|
|
ok: false as const,
|
|
error: {
|
|
kind: 'authRequired' as const,
|
|
message: 'Private repositories are not supported in VS Code yet. Use Desktop/Web.',
|
|
sshOnly: true,
|
|
},
|
|
};
|
|
}
|
|
|
|
return { ok: false as const, error: { kind: 'networkError' as const, message: combined || 'Failed to clone repository' } };
|
|
}
|
|
|
|
export async function scanSkillsRepository(options: { source: string; subpath?: string; defaultSubpath?: string }): Promise<SkillsRepoScanResult> {
|
|
const gitCheck = await assertGitAvailable();
|
|
if (!gitCheck.ok) {
|
|
return { ok: false as const, error: gitCheck.error };
|
|
}
|
|
|
|
const parsed = parseSkillRepoSource(options.source, options.subpath);
|
|
if (!parsed.ok) {
|
|
return { ok: false as const, error: parsed.error };
|
|
}
|
|
|
|
const effectiveSubpath = parsed.effectiveSubpath || options.defaultSubpath || null;
|
|
const tempBase = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'openchamber-vscode-skills-scan-'));
|
|
|
|
try {
|
|
const cloned = await cloneRepo(parsed.cloneUrlHttps, tempBase);
|
|
if (!cloned.ok) {
|
|
return { ok: false as const, error: cloned.error };
|
|
}
|
|
|
|
const toFsPath = (posixPath: string) => path.join(tempBase, ...posixPath.split('/').filter(Boolean));
|
|
|
|
const patterns = effectiveSubpath
|
|
? [`${effectiveSubpath}/SKILL.md`, `${effectiveSubpath}/**/SKILL.md`]
|
|
: ['SKILL.md', '**/SKILL.md'];
|
|
|
|
let skillMdPaths: string[] | null = null;
|
|
|
|
const sparseInit = await runGit(['-C', tempBase, 'sparse-checkout', 'init', '--no-cone'], { timeoutMs: 15_000 });
|
|
if (sparseInit.ok) {
|
|
const sparseSet = await runGit(['-C', tempBase, 'sparse-checkout', 'set', ...patterns], { timeoutMs: 30_000 });
|
|
if (sparseSet.ok) {
|
|
const checkout = await runGit(['-C', tempBase, 'checkout', '--force', 'HEAD'], { timeoutMs: 60_000 });
|
|
if (checkout.ok) {
|
|
const lsFiles = await runGit(['-C', tempBase, 'ls-files'], { 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');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!Array.isArray(skillMdPaths)) {
|
|
const listArgs = ['-C', tempBase, 'ls-tree', '-r', '--name-only', 'HEAD'];
|
|
if (effectiveSubpath) {
|
|
listArgs.push('--', effectiveSubpath);
|
|
}
|
|
|
|
const list = await runGit(listArgs, { timeoutMs: 30_000 });
|
|
if (!list.ok) {
|
|
return { ok: true as const, items: [] as SkillsCatalogItem[] };
|
|
}
|
|
|
|
skillMdPaths = list.stdout
|
|
.split(/\r?\n/)
|
|
.map((l) => l.trim())
|
|
.filter(Boolean)
|
|
.filter((p) => p.endsWith('/SKILL.md') || p === 'SKILL.md');
|
|
}
|
|
|
|
const skillDirs = Array.from(new Set(skillMdPaths.filter((p) => p !== 'SKILL.md').map((p) => path.posix.dirname(p))));
|
|
|
|
const items: SkillsCatalogItem[] = [];
|
|
|
|
for (const skillDir of skillDirs) {
|
|
const skillName = path.posix.basename(skillDir);
|
|
const skillMdPath = path.posix.join(skillDir, 'SKILL.md');
|
|
|
|
const warnings: string[] = [];
|
|
let content = '';
|
|
|
|
try {
|
|
content = await fs.promises.readFile(toFsPath(skillMdPath), 'utf8');
|
|
} catch {
|
|
const show = await runGit(['-C', tempBase, 'show', `HEAD:${skillMdPath}`], { timeoutMs: 15_000 });
|
|
if (!show.ok) {
|
|
warnings.push('Failed to read SKILL.md');
|
|
} else {
|
|
content = show.stdout;
|
|
}
|
|
}
|
|
|
|
const parsedMd = parseSkillMd(content);
|
|
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: options.source,
|
|
repoSubpath: effectiveSubpath || undefined,
|
|
skillDir,
|
|
skillName,
|
|
frontmatterName,
|
|
description,
|
|
installable,
|
|
warnings: warnings.length ? warnings : undefined,
|
|
});
|
|
}
|
|
|
|
items.sort((a, b) => String(a.skillName).localeCompare(String(b.skillName)));
|
|
|
|
return { ok: true as const, items };
|
|
} finally {
|
|
await safeRm(tempBase);
|
|
}
|
|
}
|
|
|
|
async function copyDirectoryNoSymlinks(srcDir: string, dstDir: string) {
|
|
const srcReal = await fs.promises.realpath(srcDir);
|
|
|
|
const ensureDir = async (dirPath: string) => {
|
|
await fs.promises.mkdir(dirPath, { recursive: true });
|
|
};
|
|
|
|
const walk = async (currentSrc: string, currentDst: string) => {
|
|
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');
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
await ensureDir(dstDir);
|
|
await walk(srcDir, dstDir);
|
|
}
|
|
|
|
function getUserSkillBaseDir() {
|
|
const pluralPath = path.join(os.homedir(), '.config', 'opencode', 'skills');
|
|
const legacyPath = path.join(os.homedir(), '.config', 'opencode', 'skill');
|
|
if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath;
|
|
return pluralPath;
|
|
}
|
|
|
|
function toFsPath(repoDir: string, repoRelPosixPath: string) {
|
|
const parts = repoRelPosixPath.split('/').filter(Boolean);
|
|
return path.join(repoDir, ...parts);
|
|
}
|
|
|
|
export async function installSkillsFromRepository(options: {
|
|
source: string;
|
|
subpath?: string;
|
|
scope: SkillScope;
|
|
targetSource?: SkillInstallSource;
|
|
workingDirectory?: string;
|
|
selections: Array<{ skillDir: string }>;
|
|
conflictPolicy?: 'prompt' | 'skipAll' | 'overwriteAll';
|
|
conflictDecisions?: Record<string, 'skip' | 'overwrite'>;
|
|
}): Promise<SkillsInstallResult> {
|
|
const gitCheck = await assertGitAvailable();
|
|
if (!gitCheck.ok) {
|
|
return { ok: false as const, error: gitCheck.error };
|
|
}
|
|
|
|
if (options.scope === 'project' && !options.workingDirectory) {
|
|
return { ok: false as const, error: { kind: 'invalidSource' as const, message: 'Project installs require a directory parameter' } };
|
|
}
|
|
|
|
const parsed = parseSkillRepoSource(options.source, options.subpath);
|
|
if (!parsed.ok) {
|
|
return { ok: false as const, error: parsed.error };
|
|
}
|
|
|
|
const requestedDirs = options.selections.map((s) => String(s.skillDir || '').trim()).filter(Boolean);
|
|
if (requestedDirs.length === 0) {
|
|
return { ok: false as const, error: { kind: 'invalidSource' as const, message: 'No skills selected for installation' } };
|
|
}
|
|
|
|
const userSkillDir = getUserSkillBaseDir();
|
|
const targetSource: SkillInstallSource = options.targetSource === 'agents' ? 'agents' : 'opencode';
|
|
|
|
const skillPlans = requestedDirs.map((dir) => {
|
|
const skillName = path.posix.basename(dir);
|
|
return { skillDirPosix: dir, skillName, installable: validateSkillName(skillName) };
|
|
});
|
|
|
|
const conflicts: Array<{ skillName: string; scope: SkillScope; source?: SkillInstallSource }> = [];
|
|
for (const plan of skillPlans) {
|
|
if (!plan.installable) continue;
|
|
const targetDir = options.scope === 'user'
|
|
? (targetSource === 'agents'
|
|
? path.join(os.homedir(), '.agents', 'skills', plan.skillName)
|
|
: path.join(userSkillDir, plan.skillName))
|
|
: (targetSource === 'agents'
|
|
? path.join(options.workingDirectory as string, '.agents', 'skills', plan.skillName)
|
|
: path.join(options.workingDirectory as string, '.opencode', 'skills', plan.skillName));
|
|
|
|
if (fs.existsSync(targetDir)) {
|
|
const decision = options.conflictDecisions?.[plan.skillName];
|
|
const hasAutoPolicy = options.conflictPolicy === 'skipAll' || options.conflictPolicy === 'overwriteAll';
|
|
if (!decision && !hasAutoPolicy) {
|
|
conflicts.push({ skillName: plan.skillName, scope: options.scope, source: targetSource });
|
|
}
|
|
}
|
|
}
|
|
|
|
if (conflicts.length > 0) {
|
|
return {
|
|
ok: false as const,
|
|
error: { kind: 'conflicts' as const, message: 'Some skills already exist in the selected scope', conflicts },
|
|
};
|
|
}
|
|
|
|
const tempBase = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'openchamber-vscode-skills-install-'));
|
|
|
|
try {
|
|
const cloned = await cloneRepo(parsed.cloneUrlHttps, tempBase);
|
|
if (!cloned.ok) {
|
|
return { ok: false as const, error: cloned.error };
|
|
}
|
|
|
|
await runGit(['-C', tempBase, 'sparse-checkout', 'init', '--cone'], { timeoutMs: 15_000 });
|
|
const setResult = await runGit(['-C', tempBase, 'sparse-checkout', 'set', ...requestedDirs], { timeoutMs: 30_000 });
|
|
if (!setResult.ok) {
|
|
return { ok: false as const, error: { kind: 'unknown' as const, message: setResult.stderr || setResult.message || 'Failed to configure sparse checkout' } };
|
|
}
|
|
|
|
const checkoutResult = await runGit(['-C', tempBase, 'checkout', '--force', 'HEAD'], { timeoutMs: 60_000 });
|
|
if (!checkoutResult.ok) {
|
|
return { ok: false as const, error: { kind: 'unknown' as const, message: checkoutResult.stderr || checkoutResult.message || 'Failed to checkout repository' } };
|
|
}
|
|
|
|
const installed: Array<{ skillName: string; scope: SkillScope; source?: SkillInstallSource }> = [];
|
|
const skipped: Array<{ skillName: string; reason: string }> = [];
|
|
|
|
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 = options.scope === 'user'
|
|
? (targetSource === 'agents'
|
|
? path.join(os.homedir(), '.agents', 'skills', plan.skillName)
|
|
: path.join(userSkillDir, plan.skillName))
|
|
: (targetSource === 'agents'
|
|
? path.join(options.workingDirectory as string, '.agents', 'skills', plan.skillName)
|
|
: path.join(options.workingDirectory as string, '.opencode', 'skills', plan.skillName));
|
|
|
|
const exists = fs.existsSync(targetDir);
|
|
let decision = options.conflictDecisions?.[plan.skillName] || null;
|
|
if (!decision) {
|
|
if (exists && options.conflictPolicy === 'skipAll') decision = 'skip';
|
|
if (exists && options.conflictPolicy === 'overwriteAll') decision = 'overwrite';
|
|
if (!exists) decision = 'overwrite';
|
|
}
|
|
|
|
if (exists && decision === 'skip') {
|
|
skipped.push({ skillName: plan.skillName, reason: 'Already installed (skipped)' });
|
|
continue;
|
|
}
|
|
|
|
if (exists && decision === 'overwrite') {
|
|
await safeRm(targetDir);
|
|
}
|
|
|
|
await fs.promises.mkdir(path.dirname(targetDir), { recursive: true });
|
|
|
|
try {
|
|
await copyDirectoryNoSymlinks(srcDir, targetDir);
|
|
installed.push({ skillName: plan.skillName, scope: options.scope, source: targetSource });
|
|
} catch (error) {
|
|
await safeRm(targetDir);
|
|
skipped.push({
|
|
skillName: plan.skillName,
|
|
reason: error instanceof Error ? error.message : 'Failed to copy skill files',
|
|
});
|
|
}
|
|
}
|
|
|
|
return { ok: true as const, installed, skipped };
|
|
} finally {
|
|
await safeRm(tempBase);
|
|
}
|
|
}
|
|
|
|
const catalogCache = new Map<string, { expiresAt: number; items: SkillsCatalogItem[] }>();
|
|
const CATALOG_TTL_MS = 30 * 60 * 1000;
|
|
|
|
export async function getSkillsCatalog(
|
|
workingDirectory?: string,
|
|
refresh?: boolean,
|
|
additionalSources?: SkillsCatalogSourceConfig[],
|
|
installedSkills?: Array<{ name: string; scope: SkillScope; source?: 'opencode' | 'agents' | 'claude' }>
|
|
) {
|
|
const sources = [...CURATED_SOURCES, ...(Array.isArray(additionalSources) ? additionalSources : [])];
|
|
const discovered = Array.isArray(installedSkills) ? installedSkills : discoverSkills(workingDirectory);
|
|
const installedByName = new Map(discovered.map((s) => [s.name, s]));
|
|
|
|
const itemsBySource: Record<string, SkillsCatalogItemWithBadge[]> = {};
|
|
|
|
for (const src of sources) {
|
|
const parsed = parseSkillRepoSource(src.source);
|
|
if (!parsed.ok) {
|
|
itemsBySource[src.id] = [];
|
|
continue;
|
|
}
|
|
|
|
const effectiveSubpath = src.defaultSubpath || parsed.effectiveSubpath || '';
|
|
const cacheKey = `${parsed.normalizedRepo}::${effectiveSubpath}`;
|
|
|
|
let cached = !refresh ? catalogCache.get(cacheKey) : null;
|
|
if (cached && Date.now() >= cached.expiresAt) {
|
|
catalogCache.delete(cacheKey);
|
|
cached = null;
|
|
}
|
|
|
|
let items: SkillsCatalogItem[] = [];
|
|
if (cached) {
|
|
items = cached.items;
|
|
} else {
|
|
const scanned = await scanSkillsRepository({ source: src.source, defaultSubpath: src.defaultSubpath });
|
|
if (!scanned.ok) {
|
|
itemsBySource[src.id] = [];
|
|
continue;
|
|
}
|
|
items = scanned.items || [];
|
|
catalogCache.set(cacheKey, { expiresAt: Date.now() + CATALOG_TTL_MS, items });
|
|
}
|
|
|
|
itemsBySource[src.id] = items.map((item) => {
|
|
const installed = installedByName.get(item.skillName);
|
|
return {
|
|
sourceId: src.id,
|
|
...item,
|
|
installed: installed ? { isInstalled: true, scope: installed.scope, source: installed.source === 'agents' ? 'agents' : 'opencode' } : { isInstalled: false },
|
|
};
|
|
});
|
|
}
|
|
|
|
return { ok: true as const, sources, itemsBySource };
|
|
}
|