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, renameSkill, isManagedSkillPath, 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 === '') { 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; }; // Prefer an explicit request directory, then soft-fallback to the active // project / lastDirectory so repository-local skills stay visible when the // client omits `directory` (create already used resolveProjectDirectory). const resolveSkillsDirectory = async (req) => { const optional = await resolveOptionalProjectDirectory(req); if (optional.error) { return optional; } if (optional.directory) { return optional; } try { const fallback = await resolveProjectDirectory(req); if (fallback.directory) { return { directory: fallback.directory, error: null }; } } catch { // ignore — listing user-scoped skills without a project is valid } return { directory: null, error: null }; }; app.get('/api/config/skills', async (req, res) => { try { const { directory, error } = await resolveSkillsDirectory(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); const skillPath = typeof skill.path === 'string' ? skill.path : null; return { ...skill, sources, renamable: Boolean( skillPath && skillPath !== '' && isManagedSkillPath(skillPath, directory) ), }; }); 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 resolveSkillsDirectory(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 resolveSkillsDirectory(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 resolveSkillsDirectory(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 resolveSkillsDirectory(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 resolveSkillsDirectory(req); if (error) { return res.status(400).json({ error }); } if (typeof updates?.renameTo === 'string') { const newName = updates.renameTo.trim(); console.log(`[Server] Renaming skill: ${skillName} -> ${newName}`); console.log('[Server] Working directory:', directory); renameSkill(skillName, newName, directory); await refreshOpenCodeAfterConfigChange('skill rename'); return res.json({ success: true, name: newName, requiresReload: true, message: `Skill renamed to ${newName} successfully. Reloading interface…`, reloadDelayMs: clientReloadDelayMs, }); } 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 resolveSkillsDirectory(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 resolveSkillsDirectory(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 resolveSkillsDirectory(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' }); } }); };