Files
openchamber/packages/web/server/lib/opencode/skill-routes.js
T
jkker 1663185a75 fix installed skills discovery and improve editor UX (#1296)
* fix skills discovery from opencode

* Fix stale skill description after frontmatter removal

* fix: align vscode skill discovery parity
2026-05-23 20:58:12 +03:00

724 lines
24 KiB
JavaScript

import { createOpencodeClient } from '@opencode-ai/sdk/v2';
export const registerSkillRoutes = (app, dependencies) => {
const {
fs,
path,
os,
resolveProjectDirectory,
resolveOptionalProjectDirectory,
readSettingsFromDisk,
sanitizeSkillCatalogs,
isUnsafeSkillRelativePath,
refreshOpenCodeAfterConfigChange,
clientReloadDelayMs,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
getOpenCodePort,
getSkillSources,
discoverSkills,
mergeDiscoveredSkills,
createSkill,
updateSkill,
deleteSkill,
readSkillSupportingFile,
writeSkillSupportingFile,
deleteSkillSupportingFile,
SKILL_SCOPE,
SKILL_DIR,
getCuratedSkillsSources,
getCacheKey,
getCachedScan,
setCachedScan,
parseSkillRepoSource,
scanSkillsRepository,
installSkillsFromRepository,
scanClawdHubPage,
installSkillsFromClawdHub,
isClawdHubSource,
getProfiles,
getProfile,
} = dependencies;
const findWorktreeRootForSkills = (workingDirectory) => {
if (!workingDirectory) return null;
let current = path.resolve(workingDirectory);
while (true) {
if (fs.existsSync(path.join(current, '.git'))) {
return current;
}
const parent = path.dirname(current);
if (parent === current) {
return null;
}
current = parent;
}
};
const getSkillProjectAncestors = (workingDirectory) => {
if (!workingDirectory) return [];
const result = [];
let current = path.resolve(workingDirectory);
const stop = findWorktreeRootForSkills(workingDirectory) || current;
while (true) {
result.push(current);
if (current === stop) break;
const parent = path.dirname(current);
if (parent === current) break;
current = parent;
}
return result;
};
const isPathInside = (candidatePath, parentPath) => {
if (!candidatePath || !parentPath) return false;
const normalizedCandidate = path.resolve(candidatePath);
const normalizedParent = path.resolve(parentPath);
return normalizedCandidate === normalizedParent || normalizedCandidate.startsWith(`${normalizedParent}${path.sep}`);
};
const inferSkillScopeAndSourceFromPath = (skillPath, workingDirectory) => {
const resolvedPath = typeof skillPath === 'string' ? path.resolve(skillPath) : '';
const home = os.homedir();
const source = resolvedPath.includes(`${path.sep}.agents${path.sep}skills${path.sep}`)
? 'agents'
: resolvedPath.includes(`${path.sep}.claude${path.sep}skills${path.sep}`)
? 'claude'
: 'opencode';
const projectAncestors = getSkillProjectAncestors(workingDirectory);
const isProjectScoped = projectAncestors.some((ancestor) => {
const candidates = [
path.join(ancestor, '.opencode'),
path.join(ancestor, '.claude', 'skills'),
path.join(ancestor, '.agents', 'skills'),
];
return candidates.some((candidate) => isPathInside(resolvedPath, candidate));
});
if (isProjectScoped) {
return { scope: SKILL_SCOPE.PROJECT, source };
}
const userRoots = [
path.join(home, '.config', 'opencode'),
path.join(home, '.opencode'),
path.join(home, '.claude', 'skills'),
path.join(home, '.agents', 'skills'),
process.env.OPENCODE_CONFIG_DIR ? path.resolve(process.env.OPENCODE_CONFIG_DIR) : null,
].filter(Boolean);
if (userRoots.some((root) => isPathInside(resolvedPath, root))) {
return { scope: SKILL_SCOPE.USER, source };
}
return { scope: SKILL_SCOPE.USER, source };
};
const fetchOpenCodeDiscoveredSkills = async (workingDirectory) => {
if (!getOpenCodePort()) {
return [];
}
try {
const client = createOpencodeClient({
baseUrl: buildOpenCodeUrl('/', '').replace(/\/$/, ''),
directory: workingDirectory || undefined,
headers: getOpenCodeAuthHeaders(),
fetch: (request) => fetch(request, { signal: AbortSignal.timeout(8_000) }),
});
const response = await client.app.skills(
workingDirectory ? { directory: workingDirectory } : undefined,
);
const payload = response?.data;
if (!Array.isArray(payload)) {
return [];
}
return payload
.map((item) => {
const name = typeof item?.name === 'string' ? item.name.trim() : '';
const location = typeof item?.location === 'string' ? item.location : '';
const description = typeof item?.description === 'string' ? item.description : '';
const content = typeof item?.content === 'string' ? item.content : '';
if (!name || !location) {
return null;
}
if (location === '<built-in>') {
return {
name,
path: location,
scope: SKILL_SCOPE.USER,
source: 'opencode',
description,
content,
};
}
const inferred = inferSkillScopeAndSourceFromPath(location, workingDirectory);
const skill = {
name,
path: location,
scope: inferred.scope,
source: inferred.source,
description,
};
if (content) {
skill.content = content;
}
return skill;
})
.filter(Boolean);
} catch (error) {
console.error('Failed to list OpenCode skills:', error);
return [];
}
};
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', async (req, res) => {
try {
const { directory, error } = await resolveOptionalProjectDirectory(req);
if (error) {
return res.status(400).json({ error });
}
const openCodeSkills = await fetchOpenCodeDiscoveredSkills(directory);
const localSkills = discoverSkills(directory);
const skills = mergeDiscoveredSkills(openCodeSkills, localSkills);
const enrichedSkills = skills.map((skill) => {
const sources = getSkillSources(skill.name, directory, skill);
return {
...skill,
sources
};
});
res.json({ skills: enrichedSkills });
} catch (error) {
console.error('Failed to list skills:', error);
res.status(500).json({ error: 'Failed to list skills' });
}
});
app.get('/api/config/skills/catalog', async (req, res) => {
try {
const { error } = await resolveOptionalProjectDirectory(req);
if (error) {
return res.status(400).json({ error });
}
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 sourcesForUi = sources.map(({ gitIdentityId, ...rest }) => rest);
res.json({ ok: true, sources: sourcesForUi, itemsBySource: {}, pageInfoBySource: {} });
} 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.get('/api/config/skills/catalog/source', async (req, res) => {
try {
const { directory, error } = await resolveOptionalProjectDirectory(req);
if (error) {
return res.status(400).json({ ok: false, error: { kind: 'invalidSource', message: error } });
}
const sourceId = typeof req.query.sourceId === 'string' ? req.query.sourceId : null;
if (!sourceId) {
return res.status(400).json({ ok: false, error: { kind: 'invalidSource', message: 'Missing sourceId' } });
}
const refresh = String(req.query.refresh || '').toLowerCase() === 'true';
const cursor = typeof req.query.cursor === 'string' ? req.query.cursor : null;
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 src = sources.find((entry) => entry.id === sourceId);
if (!src) {
return res.status(404).json({ ok: false, error: { kind: 'invalidSource', message: 'Unknown source' } });
}
const resolvedDiscovered = mergeDiscoveredSkills(
await fetchOpenCodeDiscoveredSkills(directory),
discoverSkills(directory),
);
const installedByName = new Map(resolvedDiscovered.map((s) => [s.name, s]));
if (src.sourceType === 'clawdhub' || isClawdHubSource(src.source)) {
const scanned = await scanClawdHubPage({ cursor: cursor || null });
if (!scanned.ok) {
return res.status(500).json({ ok: false, error: scanned.error });
}
const items = (scanned.items || []).map((item) => {
const installed = installedByName.get(item.skillName);
return {
...item,
sourceId: src.id,
installed: installed
? { isInstalled: true, scope: installed.scope, source: installed.source }
: { isInstalled: false },
};
});
return res.json({ ok: true, items, nextCursor: scanned.nextCursor || null });
}
const parsed = parseSkillRepoSource(src.source);
if (!parsed.ok) {
return res.status(400).json({ ok: false, error: parsed.error });
}
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) {
return res.status(500).json({ ok: false, error: scanned.error });
}
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, source: installed.source }
: { isInstalled: false },
};
});
return res.json({ ok: true, items });
} catch (error) {
console.error('Failed to load catalog source:', error);
return res.status(500).json({
ok: false,
error: { kind: 'unknown', message: error.message || 'Failed to load catalog source' },
});
}
});
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,
targetSource,
selections,
conflictPolicy,
conflictDecisions,
} = req.body || {};
let workingDirectory = null;
if (scope === 'project') {
const resolved = await resolveProjectDirectory(req);
if (!resolved.directory) {
return res.status(400).json({
ok: false,
error: { kind: 'invalidSource', message: resolved.error || 'Project installs require a directory parameter' },
});
}
workingDirectory = resolved.directory;
}
if (isClawdHubSource(source)) {
const result = await installSkillsFromClawdHub({
scope,
targetSource,
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 });
}
return res.status(400).json({ ok: false, error: result.error });
}
const installed = result.installed || [];
const skipped = result.skipped || [];
const requiresReload = installed.length > 0;
if (requiresReload) {
await refreshOpenCodeAfterConfigChange('skills install');
}
return res.json({
ok: true,
installed,
skipped,
requiresReload,
message: requiresReload ? 'Skills installed successfully. Reloading interface…' : 'No skills were installed',
reloadDelayMs: requiresReload ? clientReloadDelayMs : undefined,
});
}
const identity = resolveGitIdentity(gitIdentityId);
const result = await installSkillsFromRepository({
source,
subpath,
identity,
scope,
targetSource,
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 });
}
const installed = result.installed || [];
const skipped = result.skipped || [];
const requiresReload = installed.length > 0;
if (requiresReload) {
await refreshOpenCodeAfterConfigChange('skills install');
}
res.json({
ok: true,
installed,
skipped,
requiresReload,
message: requiresReload ? 'Skills installed successfully. Reloading interface…' : 'No skills were installed',
reloadDelayMs: requiresReload ? clientReloadDelayMs : undefined,
});
} 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' } });
}
});
app.get('/api/config/skills/:name', async (req, res) => {
try {
const skillName = req.params.name;
const { directory, error } = await resolveOptionalProjectDirectory(req);
if (error) {
return res.status(400).json({ error });
}
const discoveredSkill = (await fetchOpenCodeDiscoveredSkills(directory))
.find((skill) => skill.name === skillName) || null;
const sources = getSkillSources(skillName, directory, discoveredSkill);
res.json({
name: skillName,
sources: sources,
scope: sources.md.scope,
source: sources.md.source,
exists: sources.md.exists
});
} catch (error) {
console.error('Failed to get skill sources:', error);
res.status(500).json({ error: 'Failed to get skill configuration metadata' });
}
});
app.get('/api/config/skills/:name/files/*filePath', async (req, res) => {
try {
const skillName = req.params.name;
const filePath = decodeURIComponent(req.params.filePath);
if (isUnsafeSkillRelativePath(filePath)) {
return res.status(400).json({ error: 'Invalid file path' });
}
const { directory, error } = await resolveOptionalProjectDirectory(req);
if (error) {
return res.status(400).json({ error });
}
const discoveredSkill = (await fetchOpenCodeDiscoveredSkills(directory))
.find((skill) => skill.name === skillName) || null;
const sources = getSkillSources(skillName, directory, discoveredSkill);
if (!sources.md.exists || !sources.md.dir) {
return res.status(404).json({ error: 'Skill not found' });
}
const content = readSkillSupportingFile(sources.md.dir, filePath);
if (content === null) {
return res.status(404).json({ error: 'File not found' });
}
res.json({ path: filePath, content });
} catch (error) {
if (error && typeof error === 'object' && (error.code === 'EACCES' || error.code === 'EPERM')) {
return res.status(403).json({ error: 'Access to file denied' });
}
console.error('Failed to read skill file:', error);
res.status(500).json({ error: 'Failed to read skill file' });
}
});
app.post('/api/config/skills/:name', async (req, res) => {
try {
const skillName = req.params.name;
const { scope, source: skillSource, ...config } = req.body;
const { directory, error } = scope === SKILL_SCOPE.PROJECT
? await resolveProjectDirectory(req)
: await resolveOptionalProjectDirectory(req);
if (error || (scope === SKILL_SCOPE.PROJECT && !directory)) {
return res.status(400).json({ error: error || 'Project skill creation requires a directory' });
}
console.log('[Server] Creating skill:', skillName);
console.log('[Server] Scope:', scope, 'Working directory:', directory);
createSkill(skillName, { ...config, source: skillSource }, directory, scope);
await refreshOpenCodeAfterConfigChange('skill creation');
res.json({
success: true,
requiresReload: true,
message: `Skill ${skillName} created successfully. Reloading interface…`,
reloadDelayMs: clientReloadDelayMs,
});
} catch (error) {
console.error('Failed to create skill:', error);
res.status(500).json({ error: error.message || 'Failed to create skill' });
}
});
app.patch('/api/config/skills/:name', async (req, res) => {
try {
const skillName = req.params.name;
const updates = req.body;
const { directory, error } = await resolveOptionalProjectDirectory(req);
if (error) {
return res.status(400).json({ error });
}
console.log(`[Server] Updating skill: ${skillName}`);
console.log('[Server] Working directory:', directory);
updateSkill(skillName, updates, directory, updates?.targetPath);
await refreshOpenCodeAfterConfigChange('skill update');
res.json({
success: true,
requiresReload: true,
message: `Skill ${skillName} updated successfully. Reloading interface…`,
reloadDelayMs: clientReloadDelayMs,
});
} catch (error) {
console.error('[Server] Failed to update skill:', error);
res.status(500).json({ error: error.message || 'Failed to update skill' });
}
});
app.put('/api/config/skills/:name/files/*filePath', async (req, res) => {
try {
const skillName = req.params.name;
const filePath = decodeURIComponent(req.params.filePath);
if (isUnsafeSkillRelativePath(filePath)) {
return res.status(400).json({ error: 'Invalid file path' });
}
const { content } = req.body;
const { directory, error } = await resolveOptionalProjectDirectory(req);
if (error) {
return res.status(400).json({ error });
}
const discoveredSkill = (await fetchOpenCodeDiscoveredSkills(directory))
.find((skill) => skill.name === skillName) || null;
const sources = getSkillSources(skillName, directory, discoveredSkill);
if (!sources.md.exists || !sources.md.dir) {
return res.status(404).json({ error: 'Skill not found' });
}
writeSkillSupportingFile(sources.md.dir, filePath, content || '');
res.json({
success: true,
message: `File ${filePath} saved successfully`,
});
} catch (error) {
if (error && typeof error === 'object' && (error.code === 'EACCES' || error.code === 'EPERM')) {
return res.status(403).json({ error: 'Access to file denied' });
}
console.error('Failed to write skill file:', error);
res.status(500).json({ error: error.message || 'Failed to write skill file' });
}
});
app.delete('/api/config/skills/:name/files/*filePath', async (req, res) => {
try {
const skillName = req.params.name;
const filePath = decodeURIComponent(req.params.filePath);
if (isUnsafeSkillRelativePath(filePath)) {
return res.status(400).json({ error: 'Invalid file path' });
}
const { directory, error } = await resolveOptionalProjectDirectory(req);
if (error) {
return res.status(400).json({ error });
}
const discoveredSkill = (await fetchOpenCodeDiscoveredSkills(directory))
.find((skill) => skill.name === skillName) || null;
const sources = getSkillSources(skillName, directory, discoveredSkill);
if (!sources.md.exists || !sources.md.dir) {
return res.status(404).json({ error: 'Skill not found' });
}
deleteSkillSupportingFile(sources.md.dir, filePath);
res.json({
success: true,
message: `File ${filePath} deleted successfully`,
});
} catch (error) {
if (error && typeof error === 'object' && (error.code === 'EACCES' || error.code === 'EPERM')) {
return res.status(403).json({ error: 'Access to file denied' });
}
console.error('Failed to delete skill file:', error);
res.status(500).json({ error: error.message || 'Failed to delete skill file' });
}
});
app.delete('/api/config/skills/:name', async (req, res) => {
try {
const skillName = req.params.name;
const { directory, error } = await resolveOptionalProjectDirectory(req);
if (error) {
return res.status(400).json({ error });
}
deleteSkill(skillName, directory);
await refreshOpenCodeAfterConfigChange('skill deletion');
res.json({
success: true,
requiresReload: true,
message: `Skill ${skillName} deleted successfully. Reloading interface…`,
reloadDelayMs: clientReloadDelayMs,
});
} catch (error) {
console.error('Failed to delete skill:', error);
res.status(500).json({ error: error.message || 'Failed to delete skill' });
}
});
};