fix: use opencode skills as source of truth
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
|
||||
|
||||
export const registerSkillRoutes = (app, dependencies) => {
|
||||
const {
|
||||
fs,
|
||||
@@ -14,7 +16,6 @@ export const registerSkillRoutes = (app, dependencies) => {
|
||||
getOpenCodeAuthHeaders,
|
||||
getOpenCodePort,
|
||||
getSkillSources,
|
||||
discoverSkills,
|
||||
createSkill,
|
||||
updateSkill,
|
||||
deleteSkill,
|
||||
@@ -114,31 +115,23 @@ export const registerSkillRoutes = (app, dependencies) => {
|
||||
|
||||
const fetchOpenCodeDiscoveredSkills = async (workingDirectory) => {
|
||||
if (!getOpenCodePort()) {
|
||||
return null;
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(buildOpenCodeUrl('/skill', ''));
|
||||
if (workingDirectory) {
|
||||
url.searchParams.set('directory', workingDirectory);
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...getOpenCodeAuthHeaders(),
|
||||
},
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
const client = createOpencodeClient({
|
||||
baseUrl: buildOpenCodeUrl('/', '').replace(/\/$/, ''),
|
||||
directory: workingDirectory || undefined,
|
||||
headers: getOpenCodeAuthHeaders(),
|
||||
fetch: (request) => fetch(request, { signal: AbortSignal.timeout(8_000) }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const response = await client.app.skills(
|
||||
workingDirectory ? { directory: workingDirectory } : undefined,
|
||||
);
|
||||
const payload = response?.data;
|
||||
if (!Array.isArray(payload)) {
|
||||
return null;
|
||||
return [];
|
||||
}
|
||||
|
||||
return payload
|
||||
@@ -146,7 +139,7 @@ export const registerSkillRoutes = (app, dependencies) => {
|
||||
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 : '';
|
||||
if (!name || !location) {
|
||||
if (!name || !location || location === '<built-in>') {
|
||||
return null;
|
||||
}
|
||||
const inferred = inferSkillScopeAndSourceFromPath(location, workingDirectory);
|
||||
@@ -159,8 +152,9 @@ export const registerSkillRoutes = (app, dependencies) => {
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Failed to list OpenCode skills:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -191,11 +185,11 @@ export const registerSkillRoutes = (app, dependencies) => {
|
||||
|
||||
app.get('/api/config/skills', async (req, res) => {
|
||||
try {
|
||||
const { directory, error } = await resolveProjectDirectory(req);
|
||||
if (!directory) {
|
||||
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
||||
if (error) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
const skills = (await fetchOpenCodeDiscoveredSkills(directory)) || discoverSkills(directory);
|
||||
const skills = await fetchOpenCodeDiscoveredSkills(directory);
|
||||
|
||||
const enrichedSkills = skills.map((skill) => {
|
||||
const sources = getSkillSources(skill.name, directory, skill);
|
||||
@@ -277,9 +271,7 @@ export const registerSkillRoutes = (app, dependencies) => {
|
||||
return res.status(404).json({ ok: false, error: { kind: 'invalidSource', message: 'Unknown source' } });
|
||||
}
|
||||
|
||||
const discovered = directory
|
||||
? ((await fetchOpenCodeDiscoveredSkills(directory)) || discoverSkills(directory))
|
||||
: [];
|
||||
const discovered = await fetchOpenCodeDiscoveredSkills(directory);
|
||||
const installedByName = new Map(discovered.map((s) => [s.name, s]));
|
||||
|
||||
if (src.sourceType === 'clawdhub' || isClawdHubSource(src.source)) {
|
||||
@@ -504,11 +496,11 @@ export const registerSkillRoutes = (app, dependencies) => {
|
||||
app.get('/api/config/skills/:name', async (req, res) => {
|
||||
try {
|
||||
const skillName = req.params.name;
|
||||
const { directory, error } = await resolveProjectDirectory(req);
|
||||
if (!directory) {
|
||||
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
||||
if (error) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || [])
|
||||
const discoveredSkill = (await fetchOpenCodeDiscoveredSkills(directory))
|
||||
.find((skill) => skill.name === skillName) || null;
|
||||
const sources = getSkillSources(skillName, directory, discoveredSkill);
|
||||
|
||||
@@ -532,12 +524,12 @@ export const registerSkillRoutes = (app, dependencies) => {
|
||||
if (isUnsafeSkillRelativePath(filePath)) {
|
||||
return res.status(400).json({ error: 'Invalid file path' });
|
||||
}
|
||||
const { directory, error } = await resolveProjectDirectory(req);
|
||||
if (!directory) {
|
||||
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
||||
if (error) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
|
||||
const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || [])
|
||||
const discoveredSkill = (await fetchOpenCodeDiscoveredSkills(directory))
|
||||
.find((skill) => skill.name === skillName) || null;
|
||||
const sources = getSkillSources(skillName, directory, discoveredSkill);
|
||||
if (!sources.md.exists || !sources.md.dir) {
|
||||
@@ -563,9 +555,11 @@ export const registerSkillRoutes = (app, dependencies) => {
|
||||
try {
|
||||
const skillName = req.params.name;
|
||||
const { scope, source: skillSource, ...config } = req.body;
|
||||
const { directory, error } = await resolveProjectDirectory(req);
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error });
|
||||
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);
|
||||
@@ -590,15 +584,15 @@ export const registerSkillRoutes = (app, dependencies) => {
|
||||
try {
|
||||
const skillName = req.params.name;
|
||||
const updates = req.body;
|
||||
const { directory, error } = await resolveProjectDirectory(req);
|
||||
if (!directory) {
|
||||
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);
|
||||
updateSkill(skillName, updates, directory, updates?.targetPath);
|
||||
await refreshOpenCodeAfterConfigChange('skill update');
|
||||
|
||||
res.json({
|
||||
@@ -621,12 +615,12 @@ export const registerSkillRoutes = (app, dependencies) => {
|
||||
return res.status(400).json({ error: 'Invalid file path' });
|
||||
}
|
||||
const { content } = req.body;
|
||||
const { directory, error } = await resolveProjectDirectory(req);
|
||||
if (!directory) {
|
||||
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
||||
if (error) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
|
||||
const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || [])
|
||||
const discoveredSkill = (await fetchOpenCodeDiscoveredSkills(directory))
|
||||
.find((skill) => skill.name === skillName) || null;
|
||||
const sources = getSkillSources(skillName, directory, discoveredSkill);
|
||||
if (!sources.md.exists || !sources.md.dir) {
|
||||
@@ -655,12 +649,12 @@ export const registerSkillRoutes = (app, dependencies) => {
|
||||
if (isUnsafeSkillRelativePath(filePath)) {
|
||||
return res.status(400).json({ error: 'Invalid file path' });
|
||||
}
|
||||
const { directory, error } = await resolveProjectDirectory(req);
|
||||
if (!directory) {
|
||||
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
||||
if (error) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
|
||||
const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || [])
|
||||
const discoveredSkill = (await fetchOpenCodeDiscoveredSkills(directory))
|
||||
.find((skill) => skill.name === skillName) || null;
|
||||
const sources = getSkillSources(skillName, directory, discoveredSkill);
|
||||
if (!sources.md.exists || !sources.md.dir) {
|
||||
@@ -685,8 +679,8 @@ export const registerSkillRoutes = (app, dependencies) => {
|
||||
app.delete('/api/config/skills/:name', async (req, res) => {
|
||||
try {
|
||||
const skillName = req.params.name;
|
||||
const { directory, error } = await resolveProjectDirectory(req);
|
||||
if (!directory) {
|
||||
const { directory, error } = await resolveOptionalProjectDirectory(req);
|
||||
if (error) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,14 @@ function getClaudeSkillPath(workingDirectory, skillName) {
|
||||
return path.join(getClaudeSkillDir(workingDirectory, skillName), 'SKILL.md');
|
||||
}
|
||||
|
||||
function getUserClaudeSkillDir(skillName) {
|
||||
return path.join(os.homedir(), '.claude', 'skills', skillName);
|
||||
}
|
||||
|
||||
function getUserClaudeSkillPath(skillName) {
|
||||
return path.join(getUserClaudeSkillDir(skillName), 'SKILL.md');
|
||||
}
|
||||
|
||||
function getUserAgentsSkillDir(skillName) {
|
||||
return path.join(os.homedir(), '.agents', 'skills', skillName);
|
||||
}
|
||||
@@ -107,6 +115,16 @@ function getSkillScope(skillName, workingDirectory) {
|
||||
if (fs.existsSync(userPath)) {
|
||||
return { scope: SKILL_SCOPE.USER, path: userPath, source: 'opencode' };
|
||||
}
|
||||
|
||||
const userClaudePath = getUserClaudeSkillPath(skillName);
|
||||
if (fs.existsSync(userClaudePath)) {
|
||||
return { scope: SKILL_SCOPE.USER, path: userClaudePath, source: 'claude' };
|
||||
}
|
||||
|
||||
const userAgentsPath = getUserAgentsSkillPath(skillName);
|
||||
if (fs.existsSync(userAgentsPath)) {
|
||||
return { scope: SKILL_SCOPE.USER, path: userAgentsPath, source: 'agents' };
|
||||
}
|
||||
|
||||
return { scope: null, path: null, source: null };
|
||||
}
|
||||
@@ -226,11 +244,18 @@ function getSkillSources(skillName, workingDirectory, discoveredSkill = null) {
|
||||
const claudePath = workingDirectory ? getClaudeSkillPath(workingDirectory, skillName) : null;
|
||||
const claudeExists = claudePath && fs.existsSync(claudePath);
|
||||
const claudeDir = claudeExists ? path.dirname(claudePath) : null;
|
||||
const userClaudePath = getUserClaudeSkillPath(skillName);
|
||||
const userClaudeExists = fs.existsSync(userClaudePath);
|
||||
const userClaudeDir = userClaudeExists ? path.dirname(userClaudePath) : null;
|
||||
|
||||
const userPath = getUserSkillPath(skillName);
|
||||
const userExists = fs.existsSync(userPath);
|
||||
const userDir = userExists ? path.dirname(userPath) : null;
|
||||
|
||||
const userAgentsPath = getUserAgentsSkillPath(skillName);
|
||||
const userAgentsExists = fs.existsSync(userAgentsPath);
|
||||
const userAgentsDir = userAgentsExists ? path.dirname(userAgentsPath) : null;
|
||||
|
||||
const matchedDiscovered = discoveredSkill && discoveredSkill.name === skillName
|
||||
? discoveredSkill
|
||||
: discoverSkills(workingDirectory).find((skill) => skill.name === skillName);
|
||||
@@ -240,7 +265,12 @@ function getSkillSources(skillName, workingDirectory, discoveredSkill = null) {
|
||||
let mdSource = null;
|
||||
let mdDir = null;
|
||||
|
||||
if (projectExists) {
|
||||
if (matchedDiscovered?.path) {
|
||||
mdPath = matchedDiscovered.path;
|
||||
mdScope = matchedDiscovered.scope || null;
|
||||
mdSource = matchedDiscovered.source || null;
|
||||
mdDir = path.dirname(matchedDiscovered.path);
|
||||
} else if (projectExists) {
|
||||
mdPath = projectPath;
|
||||
mdScope = SKILL_SCOPE.PROJECT;
|
||||
mdSource = 'opencode';
|
||||
@@ -255,14 +285,23 @@ function getSkillSources(skillName, workingDirectory, discoveredSkill = null) {
|
||||
mdScope = SKILL_SCOPE.USER;
|
||||
mdSource = 'opencode';
|
||||
mdDir = userDir;
|
||||
} else if (matchedDiscovered?.path) {
|
||||
mdPath = matchedDiscovered.path;
|
||||
mdScope = matchedDiscovered.scope || null;
|
||||
mdSource = matchedDiscovered.source || null;
|
||||
mdDir = path.dirname(matchedDiscovered.path);
|
||||
} else if (userClaudeExists) {
|
||||
mdPath = userClaudePath;
|
||||
mdScope = SKILL_SCOPE.USER;
|
||||
mdSource = 'claude';
|
||||
mdDir = userClaudeDir;
|
||||
} else if (userAgentsExists) {
|
||||
mdPath = userAgentsPath;
|
||||
mdScope = SKILL_SCOPE.USER;
|
||||
mdSource = 'agents';
|
||||
mdDir = userAgentsDir;
|
||||
}
|
||||
|
||||
const mdExists = !!mdPath;
|
||||
const mdExists = !!mdPath && fs.existsSync(mdPath);
|
||||
if (!mdExists) {
|
||||
mdPath = null;
|
||||
mdDir = null;
|
||||
}
|
||||
|
||||
const sources = {
|
||||
md: {
|
||||
@@ -288,6 +327,16 @@ function getSkillSources(skillName, workingDirectory, discoveredSkill = null) {
|
||||
exists: userExists,
|
||||
path: userPath,
|
||||
dir: userDir
|
||||
},
|
||||
userClaudeMd: {
|
||||
exists: userClaudeExists,
|
||||
path: userClaudePath,
|
||||
dir: userClaudeDir
|
||||
},
|
||||
userAgentsMd: {
|
||||
exists: userAgentsExists,
|
||||
path: userAgentsPath,
|
||||
dir: userAgentsDir
|
||||
}
|
||||
};
|
||||
|
||||
@@ -374,22 +423,34 @@ function createSkill(skillName, config, workingDirectory, scope) {
|
||||
console.log(`Created new skill: ${skillName} (scope: ${targetScope}, path: ${targetPath})`);
|
||||
}
|
||||
|
||||
function updateSkill(skillName, updates, workingDirectory) {
|
||||
function updateSkill(skillName, updates, workingDirectory, targetPath = null) {
|
||||
ensureDirs();
|
||||
|
||||
const existing = getSkillScope(skillName, workingDirectory);
|
||||
const requestedPath = typeof targetPath === 'string' && targetPath.trim()
|
||||
? path.resolve(targetPath.trim())
|
||||
: null;
|
||||
const existing = requestedPath && fs.existsSync(requestedPath)
|
||||
? { scope: null, path: requestedPath, source: null }
|
||||
: getSkillScope(skillName, workingDirectory);
|
||||
if (!existing.path) {
|
||||
throw new Error(`Skill "${skillName}" not found`);
|
||||
}
|
||||
if (path.basename(existing.path) !== 'SKILL.md') {
|
||||
throw new Error(`Skill "${skillName}" target must be a SKILL.md file`);
|
||||
}
|
||||
|
||||
const mdPath = existing.path;
|
||||
const mdDir = path.dirname(mdPath);
|
||||
const mdData = parseMdFile(mdPath);
|
||||
const frontmatterName = typeof mdData.frontmatter?.name === 'string' ? mdData.frontmatter.name : skillName;
|
||||
if (frontmatterName !== skillName) {
|
||||
throw new Error(`Skill "${skillName}" does not match ${mdPath}`);
|
||||
}
|
||||
|
||||
let mdModified = false;
|
||||
|
||||
for (const [field, value] of Object.entries(updates)) {
|
||||
if (field === 'scope') {
|
||||
if (field === 'scope' || field === 'source' || field === 'targetPath') {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -464,6 +525,13 @@ function deleteSkill(skillName, workingDirectory) {
|
||||
deleted = true;
|
||||
}
|
||||
|
||||
const userClaudeDir = getUserClaudeSkillDir(skillName);
|
||||
if (fs.existsSync(userClaudeDir)) {
|
||||
fs.rmSync(userClaudeDir, { recursive: true, force: true });
|
||||
console.log(`Deleted user-level claude skill directory: ${userClaudeDir}`);
|
||||
deleted = true;
|
||||
}
|
||||
|
||||
if (!deleted) {
|
||||
throw new Error(`Skill "${skillName}" not found`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user