feat(skills): align discovery with Opencode API and improve skills editor UX (#441)
This commit is contained in:
committed by
GitHub
parent
7eba5141fa
commit
14737b6b28
+151
-11
@@ -6893,6 +6893,133 @@ async function main(options = {}) {
|
||||
SKILL_DIR,
|
||||
} = await import('./lib/opencode-config.js');
|
||||
|
||||
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 (!openCodePort) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
if (!Array.isArray(payload)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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 : '';
|
||||
if (!name || !location) {
|
||||
return null;
|
||||
}
|
||||
const inferred = inferSkillScopeAndSourceFromPath(location, workingDirectory);
|
||||
return {
|
||||
name,
|
||||
path: location,
|
||||
scope: inferred.scope,
|
||||
source: inferred.source,
|
||||
description,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// List all discovered skills
|
||||
app.get('/api/config/skills', async (req, res) => {
|
||||
try {
|
||||
@@ -6900,11 +7027,11 @@ async function main(options = {}) {
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
const skills = discoverSkills(directory);
|
||||
const skills = (await fetchOpenCodeDiscoveredSkills(directory)) || discoverSkills(directory);
|
||||
|
||||
// Enrich with full sources info
|
||||
const enrichedSkills = skills.map(skill => {
|
||||
const sources = getSkillSources(skill.name, directory);
|
||||
const sources = getSkillSources(skill.name, directory, skill);
|
||||
return {
|
||||
...skill,
|
||||
sources
|
||||
@@ -7018,7 +7145,9 @@ async function main(options = {}) {
|
||||
return res.status(404).json({ ok: false, error: { kind: 'invalidSource', message: 'Unknown source' } });
|
||||
}
|
||||
|
||||
const discovered = directory ? discoverSkills(directory) : [];
|
||||
const discovered = directory
|
||||
? ((await fetchOpenCodeDiscoveredSkills(directory)) || discoverSkills(directory))
|
||||
: [];
|
||||
const installedByName = new Map(discovered.map((s) => [s.name, s]));
|
||||
|
||||
if (src.sourceType === 'clawdhub' || isClawdHubSource(src.source)) {
|
||||
@@ -7033,7 +7162,7 @@ async function main(options = {}) {
|
||||
...item,
|
||||
sourceId: src.id,
|
||||
installed: installed
|
||||
? { isInstalled: true, scope: installed.scope }
|
||||
? { isInstalled: true, scope: installed.scope, source: installed.source }
|
||||
: { isInstalled: false },
|
||||
};
|
||||
});
|
||||
@@ -7077,7 +7206,7 @@ async function main(options = {}) {
|
||||
...item,
|
||||
gitIdentityId: src.gitIdentityId,
|
||||
installed: installed
|
||||
? { isInstalled: true, scope: installed.scope }
|
||||
? { isInstalled: true, scope: installed.scope, source: installed.source }
|
||||
: { isInstalled: false },
|
||||
};
|
||||
});
|
||||
@@ -7131,6 +7260,7 @@ async function main(options = {}) {
|
||||
subpath,
|
||||
gitIdentityId,
|
||||
scope,
|
||||
targetSource,
|
||||
selections,
|
||||
conflictPolicy,
|
||||
conflictDecisions,
|
||||
@@ -7152,6 +7282,7 @@ async function main(options = {}) {
|
||||
if (isClawdHubSource(source)) {
|
||||
const result = await installSkillsFromClawdHub({
|
||||
scope,
|
||||
targetSource,
|
||||
workingDirectory,
|
||||
userSkillDir: SKILL_DIR,
|
||||
selections,
|
||||
@@ -7177,6 +7308,7 @@ async function main(options = {}) {
|
||||
subpath,
|
||||
identity,
|
||||
scope,
|
||||
targetSource,
|
||||
workingDirectory,
|
||||
userSkillDir: SKILL_DIR,
|
||||
selections,
|
||||
@@ -7217,7 +7349,9 @@ async function main(options = {}) {
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
const sources = getSkillSources(skillName, directory);
|
||||
const discoveredSkill = ((await fetchOpenCodeDiscoveredSkills(directory)) || [])
|
||||
.find((skill) => skill.name === skillName) || null;
|
||||
const sources = getSkillSources(skillName, directory, discoveredSkill);
|
||||
|
||||
res.json({
|
||||
name: skillName,
|
||||
@@ -7242,7 +7376,9 @@ async function main(options = {}) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
|
||||
const sources = getSkillSources(skillName, 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) {
|
||||
return res.status(404).json({ error: 'Skill not found' });
|
||||
}
|
||||
@@ -7263,7 +7399,7 @@ async function main(options = {}) {
|
||||
app.post('/api/config/skills/:name', async (req, res) => {
|
||||
try {
|
||||
const skillName = req.params.name;
|
||||
const { scope, ...config } = req.body;
|
||||
const { scope, source: skillSource, ...config } = req.body;
|
||||
const { directory, error } = await resolveProjectDirectory(req);
|
||||
if (!directory) {
|
||||
return res.status(400).json({ error });
|
||||
@@ -7272,7 +7408,7 @@ async function main(options = {}) {
|
||||
console.log('[Server] Creating skill:', skillName);
|
||||
console.log('[Server] Scope:', scope, 'Working directory:', directory);
|
||||
|
||||
createSkill(skillName, config, directory, scope);
|
||||
createSkill(skillName, { ...config, source: skillSource }, directory, scope);
|
||||
// Skills are just files - OpenCode loads them on-demand, no restart needed
|
||||
|
||||
res.json({
|
||||
@@ -7324,7 +7460,9 @@ async function main(options = {}) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
|
||||
const sources = getSkillSources(skillName, 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) {
|
||||
return res.status(404).json({ error: 'Skill not found' });
|
||||
}
|
||||
@@ -7351,7 +7489,9 @@ async function main(options = {}) {
|
||||
return res.status(400).json({ error });
|
||||
}
|
||||
|
||||
const sources = getSkillSources(skillName, 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) {
|
||||
return res.status(404).json({ error: 'Skill not found' });
|
||||
}
|
||||
|
||||
@@ -330,11 +330,32 @@ function getClaudeSkillPath(workingDirectory, skillName) {
|
||||
return path.join(getClaudeSkillDir(workingDirectory, skillName), 'SKILL.md');
|
||||
}
|
||||
|
||||
function getUserAgentsSkillDir(skillName) {
|
||||
return path.join(os.homedir(), '.agents', 'skills', skillName);
|
||||
}
|
||||
|
||||
function getUserAgentsSkillPath(skillName) {
|
||||
return path.join(getUserAgentsSkillDir(skillName), 'SKILL.md');
|
||||
}
|
||||
|
||||
function getProjectAgentsSkillDir(workingDirectory, skillName) {
|
||||
return path.join(workingDirectory, '.agents', 'skills', skillName);
|
||||
}
|
||||
|
||||
function getProjectAgentsSkillPath(workingDirectory, skillName) {
|
||||
return path.join(getProjectAgentsSkillDir(workingDirectory, skillName), 'SKILL.md');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine skill scope based on where the SKILL.md file exists
|
||||
* Priority: project level (.opencode) > user level > claude-compat (.claude/skills)
|
||||
*/
|
||||
function getSkillScope(skillName, workingDirectory) {
|
||||
const discovered = discoverSkills(workingDirectory).find((skill) => skill.name === skillName);
|
||||
if (discovered?.path) {
|
||||
return { scope: discovered.scope || null, path: discovered.path, source: discovered.source || null };
|
||||
}
|
||||
|
||||
if (workingDirectory) {
|
||||
// Check .opencode/skill first
|
||||
const projectPath = getProjectSkillPath(workingDirectory, skillName);
|
||||
@@ -689,6 +710,129 @@ function readConfig(workingDirectory) {
|
||||
return readConfigLayers(workingDirectory).mergedConfig;
|
||||
}
|
||||
|
||||
function getAncestors(startDir, stopDir) {
|
||||
if (!startDir) return [];
|
||||
const result = [];
|
||||
let current = path.resolve(startDir);
|
||||
const resolvedStop = stopDir ? path.resolve(stopDir) : null;
|
||||
|
||||
while (true) {
|
||||
result.push(current);
|
||||
if (resolvedStop && current === resolvedStop) {
|
||||
break;
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) {
|
||||
break;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function findWorktreeRoot(startDir) {
|
||||
if (!startDir) return null;
|
||||
let current = path.resolve(startDir);
|
||||
|
||||
while (true) {
|
||||
if (fs.existsSync(path.join(current, '.git'))) {
|
||||
return current;
|
||||
}
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) {
|
||||
return null;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function walkSkillMdFiles(rootDir) {
|
||||
if (!rootDir || !fs.existsSync(rootDir)) return [];
|
||||
|
||||
const results = [];
|
||||
const walk = (dir) => {
|
||||
let entries = [];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(fullPath);
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && entry.name === 'SKILL.md') {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(rootDir);
|
||||
return results;
|
||||
}
|
||||
|
||||
function addSkillFromMdFile(skillsMap, skillMdPath, scope, source) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parseMdFile(skillMdPath);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const name = typeof parsed.frontmatter?.name === 'string'
|
||||
? parsed.frontmatter.name.trim()
|
||||
: '';
|
||||
const description = typeof parsed.frontmatter?.description === 'string'
|
||||
? parsed.frontmatter.description
|
||||
: '';
|
||||
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
skillsMap.set(name, {
|
||||
name,
|
||||
path: skillMdPath,
|
||||
scope,
|
||||
source,
|
||||
description,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveSkillSearchDirectories(workingDirectory) {
|
||||
const directories = [];
|
||||
const pushDir = (dir) => {
|
||||
if (!dir) return;
|
||||
const resolved = path.resolve(dir);
|
||||
if (!directories.includes(resolved)) {
|
||||
directories.push(resolved);
|
||||
}
|
||||
};
|
||||
|
||||
// Equivalent to Opencode Config.directories order.
|
||||
pushDir(OPENCODE_CONFIG_DIR);
|
||||
|
||||
if (workingDirectory) {
|
||||
const worktreeRoot = findWorktreeRoot(workingDirectory) || path.resolve(workingDirectory);
|
||||
const projectDirs = getAncestors(workingDirectory, worktreeRoot)
|
||||
.map((dir) => path.join(dir, '.opencode'));
|
||||
projectDirs.forEach(pushDir);
|
||||
}
|
||||
|
||||
pushDir(path.join(os.homedir(), '.opencode'));
|
||||
|
||||
const customConfigDir = process.env.OPENCODE_CONFIG_DIR
|
||||
? path.resolve(process.env.OPENCODE_CONFIG_DIR)
|
||||
: null;
|
||||
pushDir(customConfigDir);
|
||||
|
||||
return directories;
|
||||
}
|
||||
|
||||
function getConfigForPath(layers, targetPath) {
|
||||
if (!targetPath) {
|
||||
return layers.userConfig;
|
||||
@@ -1491,87 +1635,95 @@ function deleteCommand(commandName, workingDirectory) {
|
||||
*/
|
||||
function discoverSkills(workingDirectory) {
|
||||
const skills = new Map();
|
||||
|
||||
// Helper to add skill if not already found (first found wins by priority)
|
||||
const addSkill = (name, skillPath, scope, source) => {
|
||||
if (!skills.has(name)) {
|
||||
skills.set(name, { name, path: skillPath, scope, source });
|
||||
|
||||
// 1) External global (.claude, .agents)
|
||||
for (const externalRootName of ['.claude', '.agents']) {
|
||||
const homeRoot = path.join(os.homedir(), externalRootName, 'skills');
|
||||
const source = externalRootName === '.agents' ? 'agents' : 'claude';
|
||||
for (const skillMdPath of walkSkillMdFiles(homeRoot)) {
|
||||
addSkillFromMdFile(skills, skillMdPath, SKILL_SCOPE.USER, source);
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Project level .opencode/skills/ (highest priority)
|
||||
}
|
||||
|
||||
// 2) External project ancestors (.claude, .agents)
|
||||
if (workingDirectory) {
|
||||
const projectSkillDir = path.join(workingDirectory, '.opencode', 'skills');
|
||||
if (fs.existsSync(projectSkillDir)) {
|
||||
const entries = fs.readdirSync(projectSkillDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const skillMdPath = path.join(projectSkillDir, entry.name, 'SKILL.md');
|
||||
if (fs.existsSync(skillMdPath)) {
|
||||
addSkill(entry.name, skillMdPath, SKILL_SCOPE.PROJECT, 'opencode');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const legacyProjectSkillDir = path.join(workingDirectory, '.opencode', 'skill');
|
||||
if (fs.existsSync(legacyProjectSkillDir)) {
|
||||
const entries = fs.readdirSync(legacyProjectSkillDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const skillMdPath = path.join(legacyProjectSkillDir, entry.name, 'SKILL.md');
|
||||
if (fs.existsSync(skillMdPath)) {
|
||||
addSkill(entry.name, skillMdPath, SKILL_SCOPE.PROJECT, 'opencode');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Claude-compatible .claude/skills/
|
||||
const claudeSkillDir = path.join(workingDirectory, '.claude', 'skills');
|
||||
if (fs.existsSync(claudeSkillDir)) {
|
||||
const entries = fs.readdirSync(claudeSkillDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const skillMdPath = path.join(claudeSkillDir, entry.name, 'SKILL.md');
|
||||
if (fs.existsSync(skillMdPath)) {
|
||||
addSkill(entry.name, skillMdPath, SKILL_SCOPE.PROJECT, 'claude');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. User level ~/.config/opencode/skills/
|
||||
if (fs.existsSync(SKILL_DIR)) {
|
||||
const entries = fs.readdirSync(SKILL_DIR, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const skillMdPath = path.join(SKILL_DIR, entry.name, 'SKILL.md');
|
||||
if (fs.existsSync(skillMdPath)) {
|
||||
addSkill(entry.name, skillMdPath, SKILL_SCOPE.USER, 'opencode');
|
||||
const worktreeRoot = findWorktreeRoot(workingDirectory) || path.resolve(workingDirectory);
|
||||
const ancestors = getAncestors(workingDirectory, worktreeRoot);
|
||||
for (const ancestor of ancestors) {
|
||||
for (const externalRootName of ['.claude', '.agents']) {
|
||||
const source = externalRootName === '.agents' ? 'agents' : 'claude';
|
||||
const externalSkillsRoot = path.join(ancestor, externalRootName, 'skills');
|
||||
for (const skillMdPath of walkSkillMdFiles(externalSkillsRoot)) {
|
||||
addSkillFromMdFile(skills, skillMdPath, SKILL_SCOPE.PROJECT, source);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const legacyUserSkillDir = path.join(OPENCODE_CONFIG_DIR, 'skill');
|
||||
if (fs.existsSync(legacyUserSkillDir)) {
|
||||
const entries = fs.readdirSync(legacyUserSkillDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
const skillMdPath = path.join(legacyUserSkillDir, entry.name, 'SKILL.md');
|
||||
if (fs.existsSync(skillMdPath)) {
|
||||
addSkill(entry.name, skillMdPath, SKILL_SCOPE.USER, 'opencode');
|
||||
}
|
||||
// 3) Config directories: {skill,skills}/**/SKILL.md
|
||||
const configDirectories = resolveSkillSearchDirectories(workingDirectory);
|
||||
const homeOpencodeDir = path.resolve(path.join(os.homedir(), '.opencode'));
|
||||
const customConfigDir = process.env.OPENCODE_CONFIG_DIR
|
||||
? path.resolve(process.env.OPENCODE_CONFIG_DIR)
|
||||
: null;
|
||||
for (const dir of configDirectories) {
|
||||
for (const subDir of ['skill', 'skills']) {
|
||||
const root = path.join(dir, subDir);
|
||||
for (const skillMdPath of walkSkillMdFiles(root)) {
|
||||
const isUserConfigDir = dir === OPENCODE_CONFIG_DIR
|
||||
|| dir === homeOpencodeDir
|
||||
|| (customConfigDir && dir === customConfigDir);
|
||||
const scope = isUserConfigDir ? SKILL_SCOPE.USER : SKILL_SCOPE.PROJECT;
|
||||
addSkillFromMdFile(skills, skillMdPath, scope, 'opencode');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 4) Additional config.skills.paths
|
||||
let configuredPaths = [];
|
||||
try {
|
||||
const config = readConfig(workingDirectory);
|
||||
configuredPaths = Array.isArray(config?.skills?.paths) ? config.skills.paths : [];
|
||||
} catch {
|
||||
configuredPaths = [];
|
||||
}
|
||||
for (const skillPath of configuredPaths) {
|
||||
if (typeof skillPath !== 'string' || !skillPath.trim()) continue;
|
||||
const expanded = skillPath.startsWith('~/')
|
||||
? path.join(os.homedir(), skillPath.slice(2))
|
||||
: skillPath;
|
||||
const resolved = path.isAbsolute(expanded)
|
||||
? path.resolve(expanded)
|
||||
: path.resolve(workingDirectory || process.cwd(), expanded);
|
||||
for (const skillMdPath of walkSkillMdFiles(resolved)) {
|
||||
addSkillFromMdFile(skills, skillMdPath, SKILL_SCOPE.PROJECT, 'opencode');
|
||||
}
|
||||
}
|
||||
|
||||
// 5) Cached skills from config.skills.urls pulls (best-effort, no network)
|
||||
const cacheCandidates = [];
|
||||
if (process.env.XDG_CACHE_HOME) {
|
||||
cacheCandidates.push(path.join(process.env.XDG_CACHE_HOME, 'opencode', 'skills'));
|
||||
}
|
||||
cacheCandidates.push(path.join(os.homedir(), '.cache', 'opencode', 'skills'));
|
||||
cacheCandidates.push(path.join(os.homedir(), 'Library', 'Caches', 'opencode', 'skills'));
|
||||
|
||||
for (const cacheRoot of cacheCandidates) {
|
||||
if (!fs.existsSync(cacheRoot)) continue;
|
||||
const entries = fs.readdirSync(cacheRoot, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const skillRoot = path.join(cacheRoot, entry.name);
|
||||
for (const skillMdPath of walkSkillMdFiles(skillRoot)) {
|
||||
addSkillFromMdFile(skills, skillMdPath, SKILL_SCOPE.USER, 'opencode');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(skills.values());
|
||||
}
|
||||
|
||||
function getSkillSources(skillName, workingDirectory) {
|
||||
function getSkillSources(skillName, workingDirectory, discoveredSkill = null) {
|
||||
// Check all possible locations
|
||||
const projectPath = workingDirectory ? getProjectSkillPath(workingDirectory, skillName) : null;
|
||||
const projectExists = projectPath && fs.existsSync(projectPath);
|
||||
@@ -1584,6 +1736,10 @@ function getSkillSources(skillName, workingDirectory) {
|
||||
const userPath = getUserSkillPath(skillName);
|
||||
const userExists = fs.existsSync(userPath);
|
||||
const userDir = userExists ? path.dirname(userPath) : null;
|
||||
|
||||
const matchedDiscovered = discoveredSkill && discoveredSkill.name === skillName
|
||||
? discoveredSkill
|
||||
: discoverSkills(workingDirectory).find((skill) => skill.name === skillName);
|
||||
|
||||
// Determine which md file to use (priority: project > claude > user)
|
||||
let mdPath = null;
|
||||
@@ -1606,6 +1762,11 @@ function getSkillSources(skillName, workingDirectory) {
|
||||
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);
|
||||
}
|
||||
|
||||
const mdExists = !!mdPath;
|
||||
@@ -1675,14 +1836,27 @@ function createSkill(skillName, config, workingDirectory, scope) {
|
||||
let targetPath;
|
||||
let targetScope;
|
||||
|
||||
if (scope === SKILL_SCOPE.PROJECT && workingDirectory) {
|
||||
const requestedScope = scope === SKILL_SCOPE.PROJECT ? SKILL_SCOPE.PROJECT : SKILL_SCOPE.USER;
|
||||
const requestedSource = config?.source === 'agents' ? 'agents' : 'opencode';
|
||||
|
||||
if (requestedScope === SKILL_SCOPE.PROJECT && workingDirectory) {
|
||||
ensureProjectSkillDir(workingDirectory);
|
||||
targetDir = getProjectSkillDir(workingDirectory, skillName);
|
||||
targetPath = getProjectSkillPath(workingDirectory, skillName);
|
||||
if (requestedSource === 'agents') {
|
||||
targetDir = getProjectAgentsSkillDir(workingDirectory, skillName);
|
||||
targetPath = getProjectAgentsSkillPath(workingDirectory, skillName);
|
||||
} else {
|
||||
targetDir = getProjectSkillDir(workingDirectory, skillName);
|
||||
targetPath = getProjectSkillPath(workingDirectory, skillName);
|
||||
}
|
||||
targetScope = SKILL_SCOPE.PROJECT;
|
||||
} else {
|
||||
targetDir = getUserSkillDir(skillName);
|
||||
targetPath = getUserSkillPath(skillName);
|
||||
if (requestedSource === 'agents') {
|
||||
targetDir = getUserAgentsSkillDir(skillName);
|
||||
targetPath = getUserAgentsSkillPath(skillName);
|
||||
} else {
|
||||
targetDir = getUserSkillDir(skillName);
|
||||
targetPath = getUserSkillPath(skillName);
|
||||
}
|
||||
targetScope = SKILL_SCOPE.USER;
|
||||
}
|
||||
|
||||
@@ -1690,7 +1864,9 @@ function createSkill(skillName, config, workingDirectory, scope) {
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
// Extract fields - scope is only for path determination
|
||||
const { instructions, scope: _scopeFromConfig, supportingFiles, ...frontmatter } = config;
|
||||
const { instructions, scope: _scopeFromConfig, source: _sourceFromConfig, supportingFiles, ...frontmatter } = config;
|
||||
void _scopeFromConfig;
|
||||
void _sourceFromConfig;
|
||||
|
||||
// Ensure required fields
|
||||
if (!frontmatter.name) {
|
||||
@@ -1789,6 +1965,13 @@ function deleteSkill(skillName, workingDirectory) {
|
||||
console.log(`Deleted claude-compat skill directory: ${claudeDir}`);
|
||||
deleted = true;
|
||||
}
|
||||
|
||||
const projectAgentsDir = getProjectAgentsSkillDir(workingDirectory, skillName);
|
||||
if (fs.existsSync(projectAgentsDir)) {
|
||||
fs.rmSync(projectAgentsDir, { recursive: true, force: true });
|
||||
console.log(`Deleted project-level agents skill directory: ${projectAgentsDir}`);
|
||||
deleted = true;
|
||||
}
|
||||
}
|
||||
|
||||
// User level
|
||||
@@ -1799,6 +1982,13 @@ function deleteSkill(skillName, workingDirectory) {
|
||||
deleted = true;
|
||||
}
|
||||
|
||||
const userAgentsDir = getUserAgentsSkillDir(skillName);
|
||||
if (fs.existsSync(userAgentsDir)) {
|
||||
fs.rmSync(userAgentsDir, { recursive: true, force: true });
|
||||
console.log(`Deleted user-level agents skill directory: ${userAgentsDir}`);
|
||||
deleted = true;
|
||||
}
|
||||
|
||||
if (!deleted) {
|
||||
throw new Error(`Skill "${skillName}" not found`);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user