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
+141 -6
View File
@@ -5,7 +5,7 @@ import * as fs from 'fs';
import { spawn, execFile } from 'child_process';
import { promisify } from 'util';
import { type OpenCodeManager } from './opencode';
import { createAgent, createCommand, deleteAgent, deleteCommand, getAgentSources, getCommandSources, updateAgent, updateCommand, type AgentScope, type CommandScope, AGENT_SCOPE, COMMAND_SCOPE, discoverSkills, getSkillSources, createSkill, updateSkill, deleteSkill, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile, type SkillScope, SKILL_SCOPE, getProviderSources, removeProviderConfig } from './opencodeConfig';
import { createAgent, createCommand, deleteAgent, deleteCommand, getAgentSources, getCommandSources, updateAgent, updateCommand, type AgentScope, type CommandScope, AGENT_SCOPE, COMMAND_SCOPE, discoverSkills, getSkillSources, createSkill, updateSkill, deleteSkill, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile, type SkillScope, type SkillSource, type DiscoveredSkill, SKILL_SCOPE, getProviderSources, removeProviderConfig } from './opencodeConfig';
import { getProviderAuth, removeProviderAuth } from './opencodeAuth';
import { fetchQuotaForProvider, listConfiguredQuotaProviders } from './quotaProviders';
import * as gitService from './gitService';
@@ -102,6 +102,132 @@ const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/b
const OPENCHAMBER_SHARED_SETTINGS_PATH = path.join(os.homedir(), '.config', 'openchamber', 'settings.json');
const isPathInside = (candidatePath: string, parentPath: string): boolean => {
const normalizedCandidate = path.resolve(candidatePath);
const normalizedParent = path.resolve(parentPath);
return normalizedCandidate === normalizedParent || normalizedCandidate.startsWith(`${normalizedParent}${path.sep}`);
};
const findWorktreeRootForSkills = (workingDirectory?: string): string | null => {
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 getProjectAncestors = (workingDirectory?: string): string[] => {
if (!workingDirectory) return [];
const result: string[] = [];
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 inferSkillScopeAndSourceFromLocation = (location: string, workingDirectory?: string): { scope: SkillScope; source: SkillSource } => {
const resolvedPath = path.resolve(location);
const source: SkillSource = 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 = getProjectAncestors(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: 'project', source };
}
const home = os.homedir();
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((value): value is string => Boolean(value));
if (userRoots.some((root) => isPathInside(resolvedPath, root))) {
return { scope: 'user', source };
}
return { scope: 'user', source };
};
const fetchOpenCodeSkillsFromApi = async (ctx: BridgeContext | undefined, workingDirectory?: string): Promise<DiscoveredSkill[] | null> => {
const apiUrl = ctx?.manager?.getApiUrl();
if (!apiUrl) {
return null;
}
try {
const base = apiUrl.endsWith('/') ? apiUrl : `${apiUrl}/`;
const url = new URL('skill', base);
if (workingDirectory) {
url.searchParams.set('directory', workingDirectory);
}
const response = await fetch(url.toString(), {
method: 'GET',
headers: {
Accept: 'application/json',
...(ctx?.manager?.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 = inferSkillScopeAndSourceFromLocation(location, workingDirectory);
return {
name,
path: location,
scope: inferred.scope,
source: inferred.source,
description,
} as DiscoveredSkill;
})
.filter((item): item is DiscoveredSkill => item !== null);
} catch {
return null;
}
};
const readSharedSettingsFromDisk = (): Record<string, unknown> => {
try {
const raw = fs.readFileSync(OPENCHAMBER_SHARED_SETTINGS_PATH, 'utf8');
@@ -1851,7 +1977,7 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
// LIST all skills (no name provided)
if (!name && normalizedMethod === 'GET') {
const skills = discoverSkills(workingDirectory);
const skills = (await fetchOpenCodeSkillsFromApi(ctx, workingDirectory)) || discoverSkills(workingDirectory);
return { id, type, success: true, data: { skills } };
}
@@ -1861,7 +1987,9 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
}
if (normalizedMethod === 'GET') {
const sources = getSkillSources(skillName, workingDirectory);
const discoveredSkill = ((await fetchOpenCodeSkillsFromApi(ctx, workingDirectory)) || [])
.find((skill) => skill.name === skillName);
const sources = getSkillSources(skillName, workingDirectory, discoveredSkill || null);
return {
id,
type,
@@ -1872,8 +2000,10 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
if (normalizedMethod === 'POST') {
const scopeValue = body?.scope as string | undefined;
const sourceValue = body?.source as string | undefined;
const scope: SkillScope | undefined = scopeValue === 'project' ? SKILL_SCOPE.PROJECT : scopeValue === 'user' ? SKILL_SCOPE.USER : undefined;
createSkill(skillName, (body || {}) as Record<string, unknown>, workingDirectory, scope);
const normalizedSource = sourceValue === 'agents' ? 'agents' : 'opencode';
createSkill(skillName, { ...(body || {}), source: normalizedSource } as Record<string, unknown>, workingDirectory, scope);
// Skills are just files - OpenCode loads them on-demand, no restart needed
return {
id,
@@ -1949,7 +2079,8 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
.filter((v) => v !== null) as SkillsCatalogSourceConfig[])
: [];
const data = await getSkillsCatalog(workingDirectory, refresh, additionalSources);
const installedSkills = (await fetchOpenCodeSkillsFromApi(ctx, workingDirectory)) || undefined;
const data = await getSkillsCatalog(workingDirectory, refresh, additionalSources, installedSkills);
return { id, type, success: true, data };
}
@@ -1967,6 +2098,7 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
source?: string;
subpath?: string;
scope?: 'user' | 'project';
targetSource?: 'opencode' | 'agents';
selections?: Array<{ skillDir: string }>;
conflictPolicy?: 'prompt' | 'skipAll' | 'overwriteAll';
conflictDecisions?: Record<string, 'skip' | 'overwrite'>;
@@ -1978,6 +2110,7 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
source: String(body.source || ''),
subpath: body.subpath,
scope: body.scope === 'project' ? 'project' : 'user',
targetSource: body.targetSource === 'agents' ? 'agents' : 'opencode',
workingDirectory: body.scope === 'project' ? workingDirectory : undefined,
selections: Array.isArray(body.selections) ? body.selections : [],
conflictPolicy: body.conflictPolicy,
@@ -2006,7 +2139,9 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
return { id, type, success: false, error: 'File path is required' };
}
const sources = getSkillSources(skillName, workingDirectory);
const discoveredSkill = ((await fetchOpenCodeSkillsFromApi(ctx, workingDirectory)) || [])
.find((skill) => skill.name === skillName);
const sources = getSkillSources(skillName, workingDirectory, discoveredSkill || null);
if (!sources.md.dir) {
return { id, type, success: false, error: `Skill "${skillName}" not found` };
}
+245 -73
View File
@@ -275,10 +275,93 @@ const readConfigLayers = (workingDirectory?: string) => {
};
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- kept for potential future use or debugging
const readConfig = (workingDirectory?: string): Record<string, unknown> =>
readConfigLayers(workingDirectory).mergedConfig;
const getAncestors = (startDir?: string, stopDir?: string): string[] => {
if (!startDir) return [];
const result: string[] = [];
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;
};
const findWorktreeRoot = (startDir?: string): string | null => {
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;
}
};
const walkSkillMdFiles = (rootDir?: string | null): string[] => {
if (!rootDir || !fs.existsSync(rootDir)) return [];
const results: string[] = [];
const walkDir = (dir: string) => {
let entries: fs.Dirent[] = [];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walkDir(fullPath);
continue;
}
if (entry.isFile() && entry.name === 'SKILL.md') {
results.push(fullPath);
}
}
};
walkDir(rootDir);
return results;
};
const resolveSkillSearchDirectories = (workingDirectory?: string): string[] => {
const directories: string[] = [];
const pushDir = (dir?: string | null) => {
if (!dir) return;
const resolved = path.resolve(dir);
if (!directories.includes(resolved)) {
directories.push(resolved);
}
};
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'));
pushDir(process.env.OPENCODE_CONFIG_DIR ? path.resolve(process.env.OPENCODE_CONFIG_DIR) : null);
return directories;
};
const getConfigForPath = (layers: ReturnType<typeof readConfigLayers>, targetPath?: string | null) => {
if (!targetPath) return layers.userConfig;
if (layers.paths.customPath && targetPath === layers.paths.customPath) return layers.customConfig;
@@ -898,7 +981,7 @@ export const SKILL_SCOPE = {
} as const;
export type SkillScope = typeof SKILL_SCOPE[keyof typeof SKILL_SCOPE];
export type SkillSource = 'opencode' | 'claude';
export type SkillSource = 'opencode' | 'claude' | 'agents';
export type SupportingFile = {
name: string;
@@ -926,6 +1009,38 @@ export type DiscoveredSkill = {
path: string;
scope: SkillScope;
source: SkillSource;
description?: string;
};
const addSkillFromMdFile = (
skillsMap: Map<string, DiscoveredSkill>,
skillMdPath: string,
scope: SkillScope,
source: SkillSource
) => {
try {
const parsed = parseMdFile(skillMdPath);
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,
});
} catch {
// Ignore invalid SKILL.md entries.
}
};
const ensureSkillDirs = () => {
@@ -970,11 +1085,24 @@ const getClaudeSkillPath = (workingDirectory: string, skillName: string): string
return path.join(getClaudeSkillDir(workingDirectory, skillName), 'SKILL.md');
};
const getUserAgentsSkillDir = (skillName: string): string => {
return path.join(os.homedir(), '.agents', 'skills', skillName);
};
const getProjectAgentsSkillDir = (workingDirectory: string, skillName: string): string => {
return path.join(workingDirectory, '.agents', 'skills', skillName);
};
export const getSkillScope = (skillName: string, workingDirectory?: string): {
scope: SkillScope | null;
path: string | null;
source: SkillSource | null;
} => {
const discovered = discoverSkills(workingDirectory).find((skill) => skill.name === skillName);
if (discovered?.path) {
return { scope: discovered.scope, path: discovered.path, source: discovered.source };
}
if (workingDirectory) {
// Check .opencode/skill first
const projectPath = getProjectSkillPath(workingDirectory, skillName);
@@ -1026,86 +1154,100 @@ const listSupportingFiles = (skillDir: string): SupportingFile[] => {
export const discoverSkills = (workingDirectory?: string): DiscoveredSkill[] => {
const skills = new Map<string, DiscoveredSkill>();
const addSkill = (name: string, skillPath: string, scope: SkillScope, source: SkillSource) => {
if (!skills.has(name)) {
skills.set(name, { name, path: skillPath, scope, source });
// 1) External global (.claude, .agents)
for (const externalRootName of ['.claude', '.agents']) {
const source: SkillSource = externalRootName === '.agents' ? 'agents' : 'claude';
const homeRoot = path.join(os.homedir(), externalRootName, 'skills');
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: SkillSource = 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: unknown[] = [];
try {
const config = readConfig(workingDirectory);
const skillsConfig = isPlainObject(config.skills) ? config.skills : null;
configuredPaths = Array.isArray(skillsConfig?.paths) ? skillsConfig.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: string[] = [];
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());
};
export const getSkillSources = (skillName: string, workingDirectory?: string): SkillConfigSources => {
export const getSkillSources = (
skillName: string,
workingDirectory?: string,
discoveredSkill?: DiscoveredSkill | null
): SkillConfigSources => {
ensureSkillDirs();
// Check all possible locations
@@ -1120,6 +1262,10 @@ export const getSkillSources = (skillName: string, workingDirectory?: string): S
const userPath = getUserSkillPath(skillName);
const userExists = fs.existsSync(userPath);
const userDir = userExists ? getUserSkillDir(skillName) : null;
const matchedDiscovered = discoveredSkill?.name === skillName
? discoveredSkill
: discoverSkills(workingDirectory).find((skill) => skill.name === skillName);
// Determine which md file to use (priority: project > claude > user)
let mdPath: string | null = null;
@@ -1142,6 +1288,11 @@ export const getSkillSources = (skillName: string, workingDirectory?: string): S
mdScope = SKILL_SCOPE.USER;
mdSource = 'opencode';
mdDir = userDir;
} else if (matchedDiscovered?.path) {
mdPath = matchedDiscovered.path;
mdScope = matchedDiscovered.scope;
mdSource = matchedDiscovered.source;
mdDir = path.dirname(matchedDiscovered.path);
}
const mdExists = !!mdPath;
@@ -1226,22 +1377,31 @@ export const createSkill = (skillName: string, config: Record<string, unknown>,
// Determine target directory
let targetDir: string;
if (scope === SKILL_SCOPE.PROJECT && workingDirectory) {
targetDir = getProjectSkillDir(workingDirectory, skillName);
const requestedScope = scope === SKILL_SCOPE.PROJECT ? SKILL_SCOPE.PROJECT : SKILL_SCOPE.USER;
const requestedSource: SkillSource = config.source === 'agents' ? 'agents' : 'opencode';
if (requestedScope === SKILL_SCOPE.PROJECT && workingDirectory) {
targetDir = requestedSource === 'agents'
? getProjectAgentsSkillDir(workingDirectory, skillName)
: getProjectSkillDir(workingDirectory, skillName);
} else {
targetDir = getUserSkillDir(skillName);
targetDir = requestedSource === 'agents'
? getUserAgentsSkillDir(skillName)
: getUserSkillDir(skillName);
}
fs.mkdirSync(targetDir, { recursive: true });
const targetPath = path.join(targetDir, 'SKILL.md');
// Extract fields
const { instructions, scope: _ignored, supportingFiles: supportingFilesData, ...frontmatter } = config as Record<string, unknown> & {
const { instructions, scope: _ignored, source: _sourceIgnored, supportingFiles: supportingFilesData, ...frontmatter } = config as Record<string, unknown> & {
instructions?: unknown;
scope?: unknown;
source?: unknown;
supportingFiles?: Array<{ path: string; content: string }>;
};
void _ignored;
void _sourceIgnored;
// Ensure required fields
if (!frontmatter.name) {
@@ -1322,6 +1482,12 @@ export const deleteSkill = (skillName: string, workingDirectory?: string): void
fs.rmSync(claudeDir, { recursive: true, force: true });
deleted = true;
}
const projectAgentsDir = getProjectAgentsSkillDir(workingDirectory, skillName);
if (fs.existsSync(projectAgentsDir)) {
fs.rmSync(projectAgentsDir, { recursive: true, force: true });
deleted = true;
}
}
// User level
@@ -1330,6 +1496,12 @@ export const deleteSkill = (skillName: string, workingDirectory?: string): void
fs.rmSync(userDir, { recursive: true, force: true });
deleted = true;
}
const userAgentsDir = getUserAgentsSkillDir(skillName);
if (fs.existsSync(userAgentsDir)) {
fs.rmSync(userAgentsDir, { recursive: true, force: true });
deleted = true;
}
if (!deleted) {
throw new Error(`Skill "${skillName}" not found`);
+45 -23
View File
@@ -16,6 +16,7 @@ const DEFAULT_MAX_BUFFER = 4 * 1024 * 1024;
const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
type SkillScope = 'user' | 'project';
type SkillInstallSource = 'opencode' | 'agents';
export type SkillsCatalogSourceConfig = {
id: string;
@@ -56,7 +57,7 @@ export type SkillsCatalogItem = {
type SkillsCatalogItemWithBadge = SkillsCatalogItem & {
sourceId: string;
installed: { isInstalled: boolean; scope?: SkillScope };
installed: { isInstalled: boolean; scope?: SkillScope; source?: SkillInstallSource };
};
type SkillsRepoError =
@@ -65,14 +66,14 @@ type SkillsRepoError =
| { kind: 'gitUnavailable'; message: string }
| { kind: 'networkError'; message: string }
| { kind: 'unknown'; message: string }
| { kind: 'conflicts'; message: string; conflicts: Array<{ skillName: string; scope: SkillScope }> };
| { kind: 'conflicts'; message: string; conflicts: Array<{ skillName: string; scope: SkillScope; source?: SkillInstallSource }> };
type SkillsRepoScanResult =
| { ok: true; items: SkillsCatalogItem[] }
| { ok: false; error: SkillsRepoError };
type SkillsInstallResult =
| { ok: true; installed: Array<{ skillName: string; scope: SkillScope }>; skipped: Array<{ skillName: string; reason: string }> }
| { ok: true; installed: Array<{ skillName: string; scope: SkillScope; source?: SkillInstallSource }>; skipped: Array<{ skillName: string; reason: string }> }
| { ok: false; error: SkillsRepoError };
export const CURATED_SOURCES: CuratedSource[] = [
@@ -251,6 +252,7 @@ async function fetchClawdHubSkillInfo(slug: string): Promise<ClawdHubSkillInfoRe
export async function installSkillsFromClawdHub(options: {
scope: SkillScope;
targetSource?: SkillInstallSource;
workingDirectory?: string;
selections: Array<{ skillDir: string; clawdhub?: { slug: string; version: string } }>;
conflictPolicy?: 'prompt' | 'skipAll' | 'overwriteAll';
@@ -261,6 +263,7 @@ export async function installSkillsFromClawdHub(options: {
}
const userSkillDir = getUserSkillBaseDir();
const targetSource: SkillInstallSource = options.targetSource === 'agents' ? 'agents' : 'opencode';
const requestedSkills = options.selections || [];
if (requestedSkills.length === 0) {
@@ -268,20 +271,24 @@ export async function installSkillsFromClawdHub(options: {
}
// Check for conflicts
const conflicts: Array<{ skillName: string; scope: SkillScope }> = [];
const conflicts: Array<{ skillName: string; scope: SkillScope; source?: SkillInstallSource }> = [];
for (const sel of requestedSkills) {
const slug = sel.clawdhub?.slug || sel.skillDir;
if (!validateSkillName(slug)) continue;
const targetDir = options.scope === 'user'
? path.join(userSkillDir, slug)
: path.join(options.workingDirectory as string, '.opencode', 'skills', slug);
? (targetSource === 'agents'
? path.join(os.homedir(), '.agents', 'skills', slug)
: path.join(userSkillDir, slug))
: (targetSource === 'agents'
? path.join(options.workingDirectory as string, '.agents', 'skills', slug)
: path.join(options.workingDirectory as string, '.opencode', 'skills', slug));
if (fs.existsSync(targetDir)) {
const decision = options.conflictDecisions?.[slug];
const hasAutoPolicy = options.conflictPolicy === 'skipAll' || options.conflictPolicy === 'overwriteAll';
if (!decision && !hasAutoPolicy) {
conflicts.push({ skillName: slug, scope: options.scope });
conflicts.push({ skillName: slug, scope: options.scope, source: targetSource });
}
}
}
@@ -290,7 +297,7 @@ export async function installSkillsFromClawdHub(options: {
return { ok: false, error: { kind: 'conflicts', message: 'Some skills already exist in the selected scope', conflicts } };
}
const installed: Array<{ skillName: string; scope: SkillScope }> = [];
const installed: Array<{ skillName: string; scope: SkillScope; source?: SkillInstallSource }> = [];
const skipped: Array<{ skillName: string; reason: string }> = [];
for (const sel of requestedSkills) {
@@ -322,8 +329,12 @@ export async function installSkillsFromClawdHub(options: {
}
const targetDir = options.scope === 'user'
? path.join(userSkillDir, slug)
: path.join(options.workingDirectory as string, '.opencode', 'skills', slug);
? (targetSource === 'agents'
? path.join(os.homedir(), '.agents', 'skills', slug)
: path.join(userSkillDir, slug))
: (targetSource === 'agents'
? path.join(options.workingDirectory as string, '.agents', 'skills', slug)
: path.join(options.workingDirectory as string, '.opencode', 'skills', slug));
const exists = fs.existsSync(targetDir);
let decision = options.conflictDecisions?.[slug] || null;
@@ -361,7 +372,7 @@ export async function installSkillsFromClawdHub(options: {
await fs.promises.mkdir(path.dirname(targetDir), { recursive: true });
await fs.promises.rename(tempDir, targetDir);
installed.push({ skillName: slug, scope: options.scope });
installed.push({ skillName: slug, scope: options.scope, source: targetSource });
} catch (extractError) {
await safeRm(tempDir);
throw extractError;
@@ -705,6 +716,7 @@ export async function installSkillsFromRepository(options: {
source: string;
subpath?: string;
scope: SkillScope;
targetSource?: SkillInstallSource;
workingDirectory?: string;
selections: Array<{ skillDir: string }>;
conflictPolicy?: 'prompt' | 'skipAll' | 'overwriteAll';
@@ -730,24 +742,29 @@ export async function installSkillsFromRepository(options: {
}
const userSkillDir = getUserSkillBaseDir();
const targetSource: SkillInstallSource = options.targetSource === 'agents' ? 'agents' : 'opencode';
const skillPlans = requestedDirs.map((dir) => {
const skillName = path.posix.basename(dir);
return { skillDirPosix: dir, skillName, installable: validateSkillName(skillName) };
});
const conflicts: Array<{ skillName: string; scope: SkillScope }> = [];
const conflicts: Array<{ skillName: string; scope: SkillScope; source?: SkillInstallSource }> = [];
for (const plan of skillPlans) {
if (!plan.installable) continue;
const targetDir = options.scope === 'user'
? path.join(userSkillDir, plan.skillName)
: path.join(options.workingDirectory as string, '.opencode', 'skills', plan.skillName);
? (targetSource === 'agents'
? path.join(os.homedir(), '.agents', 'skills', plan.skillName)
: path.join(userSkillDir, plan.skillName))
: (targetSource === 'agents'
? path.join(options.workingDirectory as string, '.agents', 'skills', plan.skillName)
: path.join(options.workingDirectory as string, '.opencode', 'skills', plan.skillName));
if (fs.existsSync(targetDir)) {
const decision = options.conflictDecisions?.[plan.skillName];
const hasAutoPolicy = options.conflictPolicy === 'skipAll' || options.conflictPolicy === 'overwriteAll';
if (!decision && !hasAutoPolicy) {
conflicts.push({ skillName: plan.skillName, scope: options.scope });
conflicts.push({ skillName: plan.skillName, scope: options.scope, source: targetSource });
}
}
}
@@ -778,7 +795,7 @@ export async function installSkillsFromRepository(options: {
return { ok: false as const, error: { kind: 'unknown' as const, message: checkoutResult.stderr || checkoutResult.message || 'Failed to checkout repository' } };
}
const installed: Array<{ skillName: string; scope: SkillScope }> = [];
const installed: Array<{ skillName: string; scope: SkillScope; source?: SkillInstallSource }> = [];
const skipped: Array<{ skillName: string; reason: string }> = [];
for (const plan of skillPlans) {
@@ -795,8 +812,12 @@ export async function installSkillsFromRepository(options: {
}
const targetDir = options.scope === 'user'
? path.join(userSkillDir, plan.skillName)
: path.join(options.workingDirectory as string, '.opencode', 'skills', plan.skillName);
? (targetSource === 'agents'
? path.join(os.homedir(), '.agents', 'skills', plan.skillName)
: path.join(userSkillDir, plan.skillName))
: (targetSource === 'agents'
? path.join(options.workingDirectory as string, '.agents', 'skills', plan.skillName)
: path.join(options.workingDirectory as string, '.opencode', 'skills', plan.skillName));
const exists = fs.existsSync(targetDir);
let decision = options.conflictDecisions?.[plan.skillName] || null;
@@ -819,7 +840,7 @@ export async function installSkillsFromRepository(options: {
try {
await copyDirectoryNoSymlinks(srcDir, targetDir);
installed.push({ skillName: plan.skillName, scope: options.scope });
installed.push({ skillName: plan.skillName, scope: options.scope, source: targetSource });
} catch (error) {
await safeRm(targetDir);
skipped.push({
@@ -841,10 +862,11 @@ const CATALOG_TTL_MS = 30 * 60 * 1000;
export async function getSkillsCatalog(
workingDirectory?: string,
refresh?: boolean,
additionalSources?: SkillsCatalogSourceConfig[]
additionalSources?: SkillsCatalogSourceConfig[],
installedSkills?: Array<{ name: string; scope: SkillScope; source?: 'opencode' | 'agents' | 'claude' }>
) {
const sources = [...CURATED_SOURCES, ...(Array.isArray(additionalSources) ? additionalSources : [])];
const discovered = discoverSkills(workingDirectory);
const discovered = Array.isArray(installedSkills) ? installedSkills : discoverSkills(workingDirectory);
const installedByName = new Map(discovered.map((s) => [s.name, s]));
const itemsBySource: Record<string, SkillsCatalogItemWithBadge[]> = {};
@@ -877,7 +899,7 @@ export async function getSkillsCatalog(
return {
sourceId: src.id,
...item,
installed: installed ? { isInstalled: true, scope: installed.scope } : { isInstalled: false },
installed: installed ? { isInstalled: true, scope: installed.scope, source: installed.source === 'agents' ? 'agents' : 'opencode' } : { isInstalled: false },
};
});
continue;
@@ -917,7 +939,7 @@ export async function getSkillsCatalog(
return {
sourceId: src.id,
...item,
installed: installed ? { isInstalled: true, scope: installed.scope } : { isInstalled: false },
installed: installed ? { isInstalled: true, scope: installed.scope, source: installed.source === 'agents' ? 'agents' : 'opencode' } : { isInstalled: false },
};
});
}