Rename skills by moving the skill directory and updating frontmatter name instead of recreate-with-stub-description, which wiped the body and supporting files. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
741 lines
24 KiB
JavaScript
741 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,
|
|
renameSkill,
|
|
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 });
|
|
}
|
|
|
|
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 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' });
|
|
}
|
|
});
|
|
};
|