feat(skills-catalog): implement caching, curated sources, git operations, and skill installation

This commit is contained in:
Bohdan Triapitsyn
2025-12-30 21:03:25 +02:00
parent 92794d1810
commit a236dc81c9
28 changed files with 4790 additions and 7 deletions
+73
View File
@@ -4,6 +4,12 @@ import * as path from 'path';
import type { OpenCodeManager } from './opencode';
import { createAgent, createCommand, deleteAgent, deleteCommand, getAgentSources, getCommandSources, updateAgent, updateCommand, type AgentScope, type CommandScope, AGENT_SCOPE, COMMAND_SCOPE, discoverSkills, getSkillSources, createSkill, updateSkill, deleteSkill, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile, type SkillScope, SKILL_SCOPE } from './opencodeConfig';
import { removeProviderAuth } from './opencodeAuth';
import {
getSkillsCatalog,
scanSkillsRepository as scanSkillsRepositoryFromGit,
installSkillsFromRepository as installSkillsFromGit,
type SkillsCatalogSourceConfig,
} from './skillsCatalog';
export interface BridgeRequest {
id: string;
@@ -836,6 +842,73 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
}
case 'api:config/skills:catalog': {
const refresh = Boolean((payload as { refresh?: boolean } | undefined)?.refresh);
const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
const settings = readSettings(ctx);
const rawCatalogs = (settings as { skillCatalogs?: unknown }).skillCatalogs;
const additionalSources: SkillsCatalogSourceConfig[] = Array.isArray(rawCatalogs)
? (rawCatalogs
.map((entry) => {
if (!entry || typeof entry !== 'object') return null;
const candidate = entry as Record<string, unknown>;
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
const label = typeof candidate.label === 'string' ? candidate.label.trim() : '';
const source = typeof candidate.source === 'string' ? candidate.source.trim() : '';
const subpath = typeof candidate.subpath === 'string' ? candidate.subpath.trim() : '';
if (!id || !label || !source) return null;
const normalized: SkillsCatalogSourceConfig = {
id,
label,
description: source,
source,
...(subpath ? { defaultSubpath: subpath } : {}),
};
return normalized;
})
.filter((v) => v !== null) as SkillsCatalogSourceConfig[])
: [];
const data = await getSkillsCatalog(workingDirectory, refresh, additionalSources);
return { id, type, success: true, data };
}
case 'api:config/skills:scan': {
const body = (payload || {}) as { source?: string; subpath?: string; gitIdentityId?: string };
const data = await scanSkillsRepositoryFromGit({
source: String(body.source || ''),
subpath: body.subpath,
});
return { id, type, success: true, data };
}
case 'api:config/skills:install': {
const body = (payload || {}) as {
source?: string;
subpath?: string;
scope?: 'user' | 'project';
selections?: Array<{ skillDir: string }>;
conflictPolicy?: 'prompt' | 'skipAll' | 'overwriteAll';
conflictDecisions?: Record<string, 'skip' | 'overwrite'>;
};
const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
const data = await installSkillsFromGit({
source: String(body.source || ''),
subpath: body.subpath,
scope: body.scope === 'project' ? 'project' : 'user',
workingDirectory: body.scope === 'project' ? workingDirectory : undefined,
selections: Array.isArray(body.selections) ? body.selections : [],
conflictPolicy: body.conflictPolicy,
conflictDecisions: body.conflictDecisions,
});
return { id, type, success: true, data };
}
case 'api:config/skills/files': {
const { method, name, filePath, content } = (payload || {}) as {
method?: string;
+586
View File
@@ -0,0 +1,586 @@
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';
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;
};
export 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 };
};
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 }> };
type SkillsRepoScanResult =
| { ok: true; items: SkillsCatalogItem[] }
| { ok: false; error: SkillsRepoError };
type SkillsInstallResult =
| { ok: true; installed: Array<{ skillName: string; scope: SkillScope }>; skipped: Array<{ skillName: string; reason: string }> }
| { ok: false; error: SkillsRepoError };
export const CURATED_SOURCES: CuratedSource[] = [
{
id: 'anthropic',
label: 'Anthropic',
description: "Anthropics public skills repository",
source: 'anthropics/skills',
defaultSubpath: '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 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,
};
}
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`,
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() {
return path.join(os.homedir(), '.config', 'opencode', 'skill');
}
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;
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 skillPlans = requestedDirs.map((dir) => {
const skillName = path.posix.basename(dir);
return { skillDirPosix: dir, skillName, installable: validateSkillName(skillName) };
});
const conflicts: Array<{ skillName: string; scope: SkillScope }> = [];
for (const plan of skillPlans) {
if (!plan.installable) continue;
const targetDir = options.scope === 'user'
? path.join(userSkillDir, plan.skillName)
: path.join(options.workingDirectory as string, '.opencode', 'skill', 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 });
}
}
}
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 }> = [];
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'
? path.join(userSkillDir, plan.skillName)
: path.join(options.workingDirectory as string, '.opencode', 'skill', 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 });
} 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[]
) {
const sources = [...CURATED_SOURCES, ...(Array.isArray(additionalSources) ? additionalSources : [])];
const discovered = 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 } : { isInstalled: false },
};
});
}
return { ok: true as const, sources, itemsBySource };
}