Files

739 lines
25 KiB
JavaScript
Raw Permalink Normal View History

2026-05-17 00:47:51 +03:00
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
import { buildDeferredRestartResponse } from './config-mutation-response.js';
import { OPENCODE_CONFIG_DIR } from './shared.js';
2026-05-17 00:47:51 +03:00
/**
* Matches how OpenCode reads its own boolean env flags: any value other than
* unset, empty, "0" or "false" enables the flag.
*/
const isEnvFlagEnabled = (value) => {
if (typeof value !== 'string') return false;
const normalized = value.trim().toLowerCase();
return normalized.length > 0 && normalized !== '0' && normalized !== 'false';
};
export const registerSkillRoutes = (app, dependencies) => {
const {
fs,
path,
os,
resolveProjectDirectory,
resolveOptionalProjectDirectory,
readSettingsFromDisk,
sanitizeSkillCatalogs,
isUnsafeSkillRelativePath,
buildOpenCodeUrl,
getOpenCodeAuthHeaders,
getOpenCodePort,
getSkillSources,
discoverSkills,
mergeDiscoveredSkills,
createSkill,
updateSkill,
deleteSkill,
renameSkill,
isManagedSkillPath,
readSkillSupportingFile,
writeSkillSupportingFile,
deleteSkillSupportingFile,
SKILL_SCOPE,
SKILL_DIR,
getCuratedSkillsSources,
getCacheKey,
scanWithCache,
parseSkillRepoSource,
scanSkillsRepository,
installSkillsFromRepository,
fetchGitHubRepoMetas,
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 = [
OPENCODE_CONFIG_DIR,
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()) {
2026-05-17 00:47:51 +03:00
return [];
}
try {
2026-05-17 00:47:51 +03:00
const client = createOpencodeClient({
baseUrl: buildOpenCodeUrl('/', '').replace(/\/$/, ''),
directory: workingDirectory || undefined,
headers: getOpenCodeAuthHeaders(),
fetch: (request) => fetch(request, { signal: AbortSignal.timeout(8_000) }),
});
2026-05-17 00:47:51 +03:00
const response = await client.app.skills(
workingDirectory ? { directory: workingDirectory } : undefined,
);
const payload = response?.data;
if (!Array.isArray(payload)) {
2026-05-17 00:47:51 +03:00
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);
2026-05-17 00:47:51 +03:00
} 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);
2026-05-17 00:47:51 +03:00
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 !== '<built-in>'
&& isManagedSkillPath(skillPath, directory)
),
};
});
// OpenCode decides which external skill roots it loads from process
// env, and the browser cannot read that. Report the flags alongside the
// scan so the client can narrow its list to what the agent can actually
// invoke.
//
// OpenCode's own skill-list endpoint is not usable for this: on 1.18.14
// it returns only global and builtin skills, omitting the project
// `.agents`/`.claude` skills the agent demonstrably has.
res.json({
skills: enrichedSkills,
externalSkills: {
// `OPENCODE_DISABLE_CLAUDE_CODE` is the broad switch; the specific
// one wins independently — OpenCode ORs them.
claudeDisabled: isEnvFlagEnabled(process.env.OPENCODE_DISABLE_CLAUDE_CODE)
|| isEnvFlagEnabled(process.env.OPENCODE_DISABLE_CLAUDE_CODE_SKILLS),
allDisabled: isEnvFlagEnabled(process.env.OPENCODE_DISABLE_EXTERNAL_SKILLS),
},
});
} 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 githubRepos = sources
.map((src) => parseSkillRepoSource(src.source))
.filter((parsed) => parsed.ok && parsed.host === 'github.com')
.map((parsed) => parsed.normalizedRepo);
const repoMetas = await fetchGitHubRepoMetas(githubRepos);
const sourcesForUi = sources.map(({ gitIdentityId, ...rest }) => {
const parsed = parseSkillRepoSource(rest.source);
const meta = parsed.ok && parsed.host === 'github.com'
? repoMetas[parsed.normalizedRepo] || {}
: {};
return {
...rest,
stars: typeof meta.stars === 'number' ? meta.stars : null,
repoUpdatedAt: typeof meta.repoUpdatedAt === 'string' ? meta.repoUpdatedAt : null,
};
});
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.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 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]));
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 || '',
});
const scanResult = await scanWithCache(
cacheKey,
() => scanSkillsRepository({
source: src.source,
subpath: src.defaultSubpath,
defaultSubpath: src.defaultSubpath,
identity: resolveGitIdentity(src.gitIdentityId),
}),
{ refresh },
);
if (!scanResult.ok) {
return res.status(500).json({ ok: false, error: scanResult.error });
}
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;
}
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 requiresRestart = installed.length > 0;
res.json({
ok: true,
installed,
skipped,
...(requiresRestart
? buildDeferredRestartResponse('Skills installed successfully. Restart OpenCode to apply.')
: {
requiresReload: false,
message: 'No skills were installed',
}),
});
} 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);
2026-05-17 00:47:51 +03:00
if (error) {
return res.status(400).json({ error });
}
2026-05-17 00:47:51 +03:00
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);
2026-05-17 00:47:51 +03:00
if (error) {
return res.status(400).json({ error });
}
2026-05-17 00:47:51 +03:00
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;
2026-05-17 00:47:51 +03:00
const { directory, error } = scope === SKILL_SCOPE.PROJECT
? await resolveProjectDirectory(req)
: await resolveSkillsDirectory(req);
2026-05-17 00:47:51 +03:00
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);
res.json(buildDeferredRestartResponse(
`Skill ${skillName} created successfully. Restart OpenCode to apply.`,
));
} 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);
2026-05-17 00:47:51 +03:00
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);
2026-05-17 00:47:51 +03:00
updateSkill(skillName, updates, directory, updates?.targetPath);
res.json(buildDeferredRestartResponse(
`Skill ${skillName} updated successfully. Restart OpenCode to apply.`,
));
} 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);
2026-05-17 00:47:51 +03:00
if (error) {
return res.status(400).json({ error });
}
2026-05-17 00:47:51 +03:00
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);
2026-05-17 00:47:51 +03:00
if (error) {
return res.status(400).json({ error });
}
2026-05-17 00:47:51 +03:00
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);
2026-05-17 00:47:51 +03:00
if (error) {
return res.status(400).json({ error });
}
deleteSkill(skillName, directory);
res.json(buildDeferredRestartResponse(
`Skill ${skillName} deleted successfully. Restart OpenCode to apply.`,
));
} catch (error) {
console.error('Failed to delete skill:', error);
res.status(500).json({ error: error.message || 'Failed to delete skill' });
}
});
};