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` };
}