feat(skills): align discovery with Opencode API and improve skills editor UX (#441)

This commit is contained in:
Bohdan Triapitsyn
2026-02-18 12:26:08 +02:00
committed by GitHub
parent 7eba5141fa
commit 14737b6b28
15 changed files with 1231 additions and 286 deletions
@@ -43,8 +43,13 @@ async function ensureDir(dirPath) {
await fs.promises.mkdir(dirPath, { recursive: true });
}
function getTargetSkillDir({ scope, workingDirectory, userSkillDir, skillName }) {
function getTargetSkillDir({ scope, targetSource, workingDirectory, userSkillDir, skillName }) {
const source = targetSource === 'agents' ? 'agents' : 'opencode';
if (scope === 'user') {
if (source === 'agents') {
return path.join(os.homedir(), '.agents', 'skills', skillName);
}
return path.join(userSkillDir, skillName);
}
@@ -52,6 +57,10 @@ function getTargetSkillDir({ scope, workingDirectory, userSkillDir, skillName })
throw new Error('workingDirectory is required for project installs');
}
if (source === 'agents') {
return path.join(workingDirectory, '.agents', 'skills', skillName);
}
return path.join(workingDirectory, '.opencode', 'skills', skillName);
}
@@ -59,6 +68,7 @@ function getTargetSkillDir({ scope, workingDirectory, userSkillDir, skillName })
* Install skills from ClawdHub registry
* @param {Object} options
* @param {string} options.scope - 'user' or 'project'
* @param {string} [options.targetSource] - 'opencode' or 'agents'
* @param {string} [options.workingDirectory] - Required for project scope
* @param {string} options.userSkillDir - User skills directory
* @param {Array} options.selections - Array of { skillDir, clawdhub: { slug, version } }
@@ -68,6 +78,7 @@ function getTargetSkillDir({ scope, workingDirectory, userSkillDir, skillName })
*/
export async function installSkillsFromClawdHub({
scope,
targetSource,
workingDirectory,
userSkillDir,
selections,
@@ -78,6 +89,10 @@ export async function installSkillsFromClawdHub({
return { ok: false, error: { kind: 'invalidSource', message: 'Invalid scope' } };
}
if (targetSource !== undefined && targetSource !== 'opencode' && targetSource !== 'agents') {
return { ok: false, error: { kind: 'invalidSource', message: 'Invalid target source' } };
}
if (!userSkillDir) {
return { ok: false, error: { kind: 'unknown', message: 'userSkillDir is required' } };
}
@@ -114,12 +129,12 @@ export async function installSkillsFromClawdHub({
continue;
}
const targetDir = getTargetSkillDir({ scope, workingDirectory, userSkillDir, skillName: plan.slug });
const targetDir = getTargetSkillDir({ scope, targetSource, workingDirectory, userSkillDir, skillName: plan.slug });
if (fs.existsSync(targetDir)) {
const decision = conflictDecisions?.[plan.slug];
const hasAutoPolicy = conflictPolicy === 'skipAll' || conflictPolicy === 'overwriteAll';
if (!decision && !hasAutoPolicy) {
conflicts.push({ skillName: plan.slug, scope });
conflicts.push({ skillName: plan.slug, scope, source: targetSource === 'agents' ? 'agents' : 'opencode' });
}
}
}
@@ -164,7 +179,7 @@ export async function installSkillsFromClawdHub({
}
}
const targetDir = getTargetSkillDir({ scope, workingDirectory, userSkillDir, skillName: plan.slug });
const targetDir = getTargetSkillDir({ scope, targetSource, workingDirectory, userSkillDir, skillName: plan.slug });
const exists = fs.existsSync(targetDir);
// Determine conflict resolution
@@ -205,7 +220,7 @@ export async function installSkillsFromClawdHub({
await ensureDir(path.dirname(targetDir));
await fs.promises.rename(tempDir, targetDir);
installed.push({ skillName: plan.slug, scope });
installed.push({ skillName: plan.slug, scope, source: targetSource === 'agents' ? 'agents' : 'opencode' });
} catch (extractError) {
await safeRm(tempDir);
throw extractError;
@@ -105,8 +105,13 @@ async function cloneRepo({ cloneUrl, identity, tempDir }) {
};
}
function getTargetSkillDir({ scope, workingDirectory, userSkillDir, skillName }) {
function getTargetSkillDir({ scope, targetSource, workingDirectory, userSkillDir, skillName }) {
const source = targetSource === 'agents' ? 'agents' : 'opencode';
if (scope === 'user') {
if (source === 'agents') {
return path.join(os.homedir(), '.agents', 'skills', skillName);
}
return path.join(userSkillDir, skillName);
}
@@ -114,6 +119,10 @@ function getTargetSkillDir({ scope, workingDirectory, userSkillDir, skillName })
throw new Error('workingDirectory is required for project installs');
}
if (source === 'agents') {
return path.join(workingDirectory, '.agents', 'skills', skillName);
}
return path.join(workingDirectory, '.opencode', 'skills', skillName);
}
@@ -123,6 +132,7 @@ export async function installSkillsFromRepository({
defaultSubpath,
identity,
scope,
targetSource,
workingDirectory,
userSkillDir,
selections,
@@ -147,6 +157,10 @@ export async function installSkillsFromRepository({
return { ok: false, error: { kind: 'invalidSource', message: 'Invalid scope' } };
}
if (targetSource !== undefined && targetSource !== 'opencode' && targetSource !== 'agents') {
return { ok: false, error: { kind: 'invalidSource', message: 'Invalid target source' } };
}
if (scope === 'project' && !workingDirectory) {
return { ok: false, error: { kind: 'invalidSource', message: 'Project installs require a directory parameter' } };
}
@@ -178,12 +192,12 @@ export async function installSkillsFromRepository({
continue;
}
const targetDir = getTargetSkillDir({ scope, workingDirectory, userSkillDir, skillName: plan.skillName });
const targetDir = getTargetSkillDir({ scope, targetSource, workingDirectory, userSkillDir, skillName: plan.skillName });
if (fs.existsSync(targetDir)) {
const decision = conflictDecisions?.[plan.skillName];
const hasAutoPolicy = conflictPolicy === 'skipAll' || conflictPolicy === 'overwriteAll';
if (!decision && !hasAutoPolicy) {
conflicts.push({ skillName: plan.skillName, scope });
conflicts.push({ skillName: plan.skillName, scope, source: targetSource === 'agents' ? 'agents' : 'opencode' });
}
}
}
@@ -239,7 +253,7 @@ export async function installSkillsFromRepository({
continue;
}
const targetDir = getTargetSkillDir({ scope, workingDirectory, userSkillDir, skillName: plan.skillName });
const targetDir = getTargetSkillDir({ scope, targetSource, workingDirectory, userSkillDir, skillName: plan.skillName });
const exists = fs.existsSync(targetDir);
let decision = conflictDecisions?.[plan.skillName] || null;
@@ -263,7 +277,7 @@ export async function installSkillsFromRepository({
try {
await copyDirectoryNoSymlinks(srcDir, targetDir);
installed.push({ skillName: plan.skillName, scope });
installed.push({ skillName: plan.skillName, scope, source: targetSource === 'agents' ? 'agents' : 'opencode' });
} catch (error) {
await safeRm(targetDir);
skipped.push({