feat(skills-catalog): implement caching, curated sources, git operations, and skill installation
This commit is contained in:
@@ -350,6 +350,39 @@ const normalizeStringArray = (input) => {
|
||||
);
|
||||
};
|
||||
|
||||
const sanitizeSkillCatalogs = (input) => {
|
||||
if (!Array.isArray(input)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result = [];
|
||||
const seen = new Set();
|
||||
|
||||
for (const entry of input) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
|
||||
const id = typeof entry.id === 'string' ? entry.id.trim() : '';
|
||||
const label = typeof entry.label === 'string' ? entry.label.trim() : '';
|
||||
const source = typeof entry.source === 'string' ? entry.source.trim() : '';
|
||||
const subpath = typeof entry.subpath === 'string' ? entry.subpath.trim() : '';
|
||||
const gitIdentityId = typeof entry.gitIdentityId === 'string' ? entry.gitIdentityId.trim() : '';
|
||||
|
||||
if (!id || !label || !source) continue;
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
|
||||
result.push({
|
||||
id,
|
||||
label,
|
||||
source,
|
||||
...(subpath ? { subpath } : {}),
|
||||
...(gitIdentityId ? { gitIdentityId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const sanitizeSettingsUpdate = (payload) => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return {};
|
||||
@@ -425,6 +458,11 @@ const sanitizeSettingsUpdate = (payload) => {
|
||||
result.queueModeEnabled = candidate.queueModeEnabled;
|
||||
}
|
||||
|
||||
const skillCatalogs = sanitizeSkillCatalogs(candidate.skillCatalogs);
|
||||
if (skillCatalogs) {
|
||||
result.skillCatalogs = skillCatalogs;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
@@ -2376,7 +2414,8 @@ async function main(options = {}) {
|
||||
readSkillSupportingFile,
|
||||
writeSkillSupportingFile,
|
||||
deleteSkillSupportingFile,
|
||||
SKILL_SCOPE
|
||||
SKILL_SCOPE,
|
||||
SKILL_DIR,
|
||||
} = await import('./lib/opencode-config.js');
|
||||
|
||||
// List all discovered skills
|
||||
@@ -2401,6 +2440,210 @@ async function main(options = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
// ============== SKILLS CATALOG + INSTALL ENDPOINTS ==============
|
||||
|
||||
const { getCuratedSkillsSources } = await import('./lib/skills-catalog/curated-sources.js');
|
||||
const { getCacheKey, getCachedScan, setCachedScan } = await import('./lib/skills-catalog/cache.js');
|
||||
const { parseSkillRepoSource } = await import('./lib/skills-catalog/source.js');
|
||||
const { scanSkillsRepository } = await import('./lib/skills-catalog/scan.js');
|
||||
const { installSkillsFromRepository } = await import('./lib/skills-catalog/install.js');
|
||||
const { getProfiles, getProfile } = await import('./lib/git-identity-storage.js');
|
||||
|
||||
const listGitIdentitiesForResponse = () => {
|
||||
try {
|
||||
const profiles = getProfiles();
|
||||
return profiles.map((p) => ({ id: p.id, name: p.name }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const resolveGitIdentity = (profileId) => {
|
||||
if (!profileId) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const profile = getProfile(profileId);
|
||||
const sshKey = profile?.sshKey;
|
||||
if (typeof sshKey === 'string' && sshKey.trim()) {
|
||||
return { sshKey: sshKey.trim() };
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
app.get('/api/config/skills/catalog', async (req, res) => {
|
||||
try {
|
||||
const workingDirectory = req.query.directory || openCodeWorkingDirectory;
|
||||
const refresh = String(req.query.refresh || '').toLowerCase() === 'true';
|
||||
|
||||
const curatedSources = getCuratedSkillsSources();
|
||||
const settings = await readSettingsFromDisk();
|
||||
const customSourcesRaw = sanitizeSkillCatalogs(settings.skillCatalogs) || [];
|
||||
|
||||
const customSources = customSourcesRaw.map((entry) => ({
|
||||
id: entry.id,
|
||||
label: entry.label,
|
||||
description: entry.source,
|
||||
source: entry.source,
|
||||
defaultSubpath: entry.subpath,
|
||||
gitIdentityId: entry.gitIdentityId,
|
||||
}));
|
||||
|
||||
const sources = [...curatedSources, ...customSources];
|
||||
|
||||
const discovered = discoverSkills(workingDirectory);
|
||||
const installedByName = new Map(discovered.map((s) => [s.name, s]));
|
||||
|
||||
const itemsBySource = {};
|
||||
|
||||
for (const src of sources) {
|
||||
const parsed = parseSkillRepoSource(src.source);
|
||||
if (!parsed.ok) {
|
||||
itemsBySource[src.id] = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
const effectiveSubpath = src.defaultSubpath || parsed.effectiveSubpath || null;
|
||||
const cacheKey = getCacheKey({
|
||||
normalizedRepo: parsed.normalizedRepo,
|
||||
subpath: effectiveSubpath || '',
|
||||
identityId: src.gitIdentityId || '',
|
||||
});
|
||||
|
||||
let scanResult = !refresh ? getCachedScan(cacheKey) : null;
|
||||
if (!scanResult) {
|
||||
const scanned = await scanSkillsRepository({
|
||||
source: src.source,
|
||||
subpath: src.defaultSubpath,
|
||||
defaultSubpath: src.defaultSubpath,
|
||||
identity: resolveGitIdentity(src.gitIdentityId),
|
||||
});
|
||||
|
||||
if (!scanned.ok) {
|
||||
itemsBySource[src.id] = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
scanResult = scanned;
|
||||
setCachedScan(cacheKey, scanResult);
|
||||
}
|
||||
|
||||
const items = (scanResult.items || []).map((item) => {
|
||||
const installed = installedByName.get(item.skillName);
|
||||
return {
|
||||
sourceId: src.id,
|
||||
...item,
|
||||
gitIdentityId: src.gitIdentityId,
|
||||
installed: installed
|
||||
? { isInstalled: true, scope: installed.scope }
|
||||
: { isInstalled: false },
|
||||
};
|
||||
});
|
||||
|
||||
itemsBySource[src.id] = items;
|
||||
}
|
||||
|
||||
const sourcesForUi = sources.map(({ gitIdentityId, ...rest }) => rest);
|
||||
res.json({ ok: true, sources: sourcesForUi, itemsBySource });
|
||||
} catch (error) {
|
||||
console.error('Failed to load skills catalog:', error);
|
||||
res.status(500).json({ ok: false, error: { kind: 'unknown', message: error.message || 'Failed to load catalog' } });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/config/skills/scan', async (req, res) => {
|
||||
try {
|
||||
const { source, subpath, gitIdentityId } = req.body || {};
|
||||
const identity = resolveGitIdentity(gitIdentityId);
|
||||
|
||||
const result = await scanSkillsRepository({
|
||||
source,
|
||||
subpath,
|
||||
identity,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
if (result.error?.kind === 'authRequired') {
|
||||
return res.status(401).json({
|
||||
ok: false,
|
||||
error: {
|
||||
...result.error,
|
||||
identities: listGitIdentitiesForResponse(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(400).json({ ok: false, error: result.error });
|
||||
}
|
||||
|
||||
res.json({ ok: true, items: result.items });
|
||||
} catch (error) {
|
||||
console.error('Failed to scan skills repository:', error);
|
||||
res.status(500).json({ ok: false, error: { kind: 'unknown', message: error.message || 'Failed to scan repository' } });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/config/skills/install', async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
source,
|
||||
subpath,
|
||||
gitIdentityId,
|
||||
scope,
|
||||
selections,
|
||||
conflictPolicy,
|
||||
conflictDecisions,
|
||||
} = req.body || {};
|
||||
|
||||
const workingDirectory = req.query.directory;
|
||||
if (scope === 'project' && !workingDirectory) {
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
error: { kind: 'invalidSource', message: 'Project installs require a directory parameter' },
|
||||
});
|
||||
}
|
||||
const identity = resolveGitIdentity(gitIdentityId);
|
||||
|
||||
const result = await installSkillsFromRepository({
|
||||
source,
|
||||
subpath,
|
||||
identity,
|
||||
scope,
|
||||
workingDirectory,
|
||||
userSkillDir: SKILL_DIR,
|
||||
selections,
|
||||
conflictPolicy,
|
||||
conflictDecisions,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
if (result.error?.kind === 'conflicts') {
|
||||
return res.status(409).json({ ok: false, error: result.error });
|
||||
}
|
||||
|
||||
if (result.error?.kind === 'authRequired') {
|
||||
return res.status(401).json({
|
||||
ok: false,
|
||||
error: {
|
||||
...result.error,
|
||||
identities: listGitIdentitiesForResponse(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(400).json({ ok: false, error: result.error });
|
||||
}
|
||||
|
||||
res.json({ ok: true, installed: result.installed || [], skipped: result.skipped || [] });
|
||||
} catch (error) {
|
||||
console.error('Failed to install skills:', error);
|
||||
res.status(500).json({ ok: false, error: { kind: 'unknown', message: error.message || 'Failed to install skills' } });
|
||||
}
|
||||
});
|
||||
|
||||
// Get single skill sources
|
||||
app.get('/api/config/skills/:name', (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export const CURATED_SKILLS_SOURCES = [
|
||||
{
|
||||
id: 'anthropic',
|
||||
label: 'Anthropic',
|
||||
description: "Anthropic’s public skills repository",
|
||||
source: 'anthropics/skills',
|
||||
defaultSubpath: 'skills',
|
||||
},
|
||||
];
|
||||
|
||||
export function getCuratedSkillsSources() {
|
||||
return CURATED_SKILLS_SOURCES.slice();
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
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 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, workingDirectory, userSkillDir, skillName }) {
|
||||
if (scope === 'user') {
|
||||
return path.join(userSkillDir, skillName);
|
||||
}
|
||||
|
||||
if (!workingDirectory) {
|
||||
throw new Error('workingDirectory is required for project installs');
|
||||
}
|
||||
|
||||
return path.join(workingDirectory, '.opencode', 'skill', skillName);
|
||||
}
|
||||
|
||||
export async function installSkillsFromRepository({
|
||||
source,
|
||||
subpath,
|
||||
defaultSubpath,
|
||||
identity,
|
||||
scope,
|
||||
workingDirectory,
|
||||
userSkillDir,
|
||||
selections,
|
||||
conflictPolicy,
|
||||
conflictDecisions,
|
||||
} = {}) {
|
||||
const gitCheck = await assertGitAvailable();
|
||||
if (!gitCheck.ok) {
|
||||
return { ok: false, error: gitCheck.error };
|
||||
}
|
||||
|
||||
if (scope !== 'user' && scope !== 'project') {
|
||||
return { ok: false, error: { kind: 'invalidSource', message: 'Invalid scope' } };
|
||||
}
|
||||
|
||||
if (!userSkillDir) {
|
||||
return { ok: false, error: { kind: 'unknown', message: 'userSkillDir is required' } };
|
||||
}
|
||||
|
||||
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, 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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, 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 });
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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' } };
|
||||
}
|
||||
Reference in New Issue
Block a user