fix installed skills discovery and improve editor UX (#1296)

* fix skills discovery from opencode

* Fix stale skill description after frontmatter removal

* fix: align vscode skill discovery parity
This commit is contained in:
jkker
2026-05-23 20:58:12 +03:00
committed by GitHub
parent fd01a0ba62
commit 1663185a75
13 changed files with 635 additions and 65 deletions
+14 -4
View File
@@ -16,6 +16,7 @@ import {
AGENT_SCOPE,
COMMAND_SCOPE,
discoverSkills,
mergeDiscoveredSkills,
getSkillSources,
createSkill,
updateSkill,
@@ -93,6 +94,15 @@ const parseSkillsCatalogSources = (settings: Record<string, unknown>): SkillsCat
.filter((value): value is SkillsCatalogSourceConfig => value !== null);
};
const resolveDiscoveredSkills = async (
deps: ConfigRuntimeDeps,
ctx: BridgeContext | undefined,
workingDirectory?: string,
): Promise<DiscoveredSkill[]> => mergeDiscoveredSkills(
(await deps.fetchOpenCodeSkillsFromApi(ctx, workingDirectory)) || [],
discoverSkills(workingDirectory),
);
export async function handleConfigBridgeMessage(
message: BridgeMessageInput,
ctx: BridgeContext | undefined,
@@ -458,7 +468,7 @@ export async function handleConfigBridgeMessage(
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
if (!name && normalizedMethod === 'GET') {
const skills = (await deps.fetchOpenCodeSkillsFromApi(ctx, workingDirectory)) || discoverSkills(workingDirectory);
const skills = await resolveDiscoveredSkills(deps, ctx, workingDirectory);
return { id, type, success: true, data: { skills } };
}
@@ -468,7 +478,7 @@ export async function handleConfigBridgeMessage(
}
if (normalizedMethod === 'GET') {
const discoveredSkill = ((await deps.fetchOpenCodeSkillsFromApi(ctx, workingDirectory)) || [])
const discoveredSkill = (await resolveDiscoveredSkills(deps, ctx, workingDirectory))
.find((skill) => skill.name === skillName);
const sources = getSkillSources(skillName, workingDirectory, discoveredSkill || null);
return {
@@ -539,7 +549,7 @@ export async function handleConfigBridgeMessage(
const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
const settings = deps.readSettings(ctx);
const additionalSources = parseSkillsCatalogSources(settings);
const installedSkills = (await deps.fetchOpenCodeSkillsFromApi(ctx, workingDirectory)) || undefined;
const installedSkills = await resolveDiscoveredSkills(deps, ctx, workingDirectory);
const data = await getSkillsCatalog(workingDirectory, refresh, additionalSources, installedSkills);
return { id, type, success: true, data };
}
@@ -623,7 +633,7 @@ export async function handleConfigBridgeMessage(
return { id, type, success: false, error: 'File path is required' };
}
const discoveredSkill = ((await deps.fetchOpenCodeSkillsFromApi(ctx, workingDirectory)) || [])
const discoveredSkill = (await resolveDiscoveredSkills(deps, ctx, workingDirectory))
.find((skill) => skill.name === skillName);
const sources = getSkillSources(skillName, workingDirectory, discoveredSkill || null);
if (!sources.md.dir) {
+13 -1
View File
@@ -2,7 +2,7 @@ import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as vscode from 'vscode';
import { type DiscoveredSkill, type SkillScope, type SkillSource } from './opencodeConfig';
import { BUILT_IN_SKILL_LOCATION, type DiscoveredSkill, type SkillScope, type SkillSource } from './opencodeConfig';
import type { BridgeContext } from './bridge';
const SETTINGS_KEY = 'openchamber.settings';
@@ -129,9 +129,20 @@ export const fetchOpenCodeSkillsFromApi = async (
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 : '';
const content = typeof item?.content === 'string' ? item.content : '';
if (!name || !location) {
return null;
}
if (location === BUILT_IN_SKILL_LOCATION) {
return {
name,
path: location,
scope: 'user',
source: 'opencode',
description,
content,
} as DiscoveredSkill;
}
const inferred = inferSkillScopeAndSourceFromLocation(location, workingDirectory);
return {
name,
@@ -139,6 +150,7 @@ export const fetchOpenCodeSkillsFromApi = async (
scope: inferred.scope,
source: inferred.source,
description,
content,
} as DiscoveredSkill;
})
.filter((item): item is DiscoveredSkill => item !== null);
@@ -0,0 +1,46 @@
import { describe, expect, test } from 'bun:test';
import {
BUILT_IN_SKILL_LOCATION,
getSkillSources,
mergeDiscoveredSkills,
} from './opencodeConfig';
describe('VS Code skill discovery parity', () => {
test('merges OpenCode API skills with locally discovered fallback skills', () => {
const merged = mergeDiscoveredSkills(
[
{ name: 'built-in', path: BUILT_IN_SKILL_LOCATION, scope: 'user', source: 'opencode' },
{ name: 'local-first', path: '/tmp/local-first/SKILL.md', scope: 'user', source: 'agents' },
],
[
{ name: 'local-first', path: '/tmp/local-first/SKILL.md', scope: 'user', source: 'agents' },
{ name: 'local-only', path: '/tmp/local-only/SKILL.md', scope: 'project', source: 'claude' },
],
);
expect(merged.map((skill) => skill.name)).toEqual(['built-in', 'local-first', 'local-only']);
});
test('resolves built-in skills without treating the virtual location as a file', () => {
const discoveredSkill = {
name: 'customize-opencode',
path: BUILT_IN_SKILL_LOCATION,
scope: 'user',
source: 'opencode',
description: 'Customize opencode',
content: '# Customize opencode\n\nUse for config work.',
};
const sources = getSkillSources('customize-opencode', '/tmp/openchamber-vscode-skills-test', discoveredSkill);
expect(sources.md.exists).toBe(true);
expect(sources.md.path).toBeNull();
expect(sources.md.dir).toBeNull();
expect(sources.md.scope).toBe('user');
expect(sources.md.source).toBe('opencode');
expect(sources.md.description).toBe('Customize opencode');
expect(sources.md.instructions).toBe('# Customize opencode\n\nUse for config work.');
expect(sources.md.fields).toEqual(['description', 'instructions']);
});
});
+60 -9
View File
@@ -1311,6 +1311,9 @@ export type SkillConfigSources = {
scope?: SkillScope | null;
source?: SkillSource | null;
supportingFiles: SupportingFile[];
name?: string;
description?: string;
instructions?: string;
};
projectMd?: { exists: boolean; path: string | null };
claudeMd?: { exists: boolean; path: string | null };
@@ -1323,6 +1326,34 @@ export type DiscoveredSkill = {
scope: SkillScope;
source: SkillSource;
description?: string;
content?: string;
};
export const BUILT_IN_SKILL_LOCATION = '<built-in>';
export const mergeDiscoveredSkills = (
primarySkills: DiscoveredSkill[] = [],
fallbackSkills: DiscoveredSkill[] = []
): DiscoveredSkill[] => {
const merged: DiscoveredSkill[] = [];
const seenNames = new Set<string>();
const appendSkill = (skill: DiscoveredSkill | null | undefined) => {
if (!skill) {
return;
}
const name = typeof skill?.name === 'string' ? skill.name.trim() : '';
if (!name || seenNames.has(name)) {
return;
}
seenNames.add(name);
merged.push(skill);
};
for (const skill of primarySkills || []) appendSkill(skill);
for (const skill of fallbackSkills || []) appendSkill(skill);
return merged;
};
const addSkillFromMdFile = (
@@ -1562,6 +1593,14 @@ export const getSkillSources = (
discoveredSkill?: DiscoveredSkill | null
): SkillConfigSources => {
ensureSkillDirs();
const isReadableFile = (filePath: string | null): boolean => {
if (!filePath) return false;
try {
return fs.statSync(filePath).isFile();
} catch {
return false;
}
};
// Check all possible locations
const projectPath = workingDirectory ? getProjectSkillPath(workingDirectory, skillName) : null;
@@ -1579,6 +1618,8 @@ export const getSkillSources = (
const matchedDiscovered = discoveredSkill?.name === skillName
? discoveredSkill
: discoverSkills(workingDirectory).find((skill) => skill.name === skillName);
const discoveredPath = typeof matchedDiscovered?.path === 'string' ? matchedDiscovered.path : null;
const isBuiltInDiscovered = discoveredPath === BUILT_IN_SKILL_LOCATION;
// Determine which md file to use (priority: project > claude > user)
let mdPath: string | null = null;
@@ -1586,7 +1627,15 @@ export const getSkillSources = (
let mdSource: SkillSource | null = null;
let mdDir: string | null = null;
if (projectExists) {
if (isBuiltInDiscovered) {
mdScope = matchedDiscovered?.scope || SKILL_SCOPE.USER;
mdSource = matchedDiscovered?.source || 'opencode';
} else if (discoveredPath && isReadableFile(discoveredPath)) {
mdPath = discoveredPath;
mdScope = matchedDiscovered?.scope || null;
mdSource = matchedDiscovered?.source || null;
mdDir = path.dirname(discoveredPath);
} else if (projectExists) {
mdPath = projectPath;
mdScope = SKILL_SCOPE.PROJECT;
mdSource = 'opencode';
@@ -1601,21 +1650,20 @@ export const getSkillSources = (
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;
let mdFields: string[] = [];
const mdExists = isBuiltInDiscovered || !!mdPath;
let mdFields: string[] = isBuiltInDiscovered ? ['description', 'instructions'] : [];
let supportingFiles: SupportingFile[] = [];
let mdDescription = typeof matchedDiscovered?.description === 'string' ? matchedDiscovered.description : '';
let mdInstructions = isBuiltInDiscovered && typeof matchedDiscovered?.content === 'string' ? matchedDiscovered.content : '';
if (mdExists && mdPath) {
const { frontmatter, body } = parseMdFile(mdPath);
mdFields = Object.keys(frontmatter);
mdDescription = typeof frontmatter.description === 'string' ? frontmatter.description : '';
if (body) mdFields.push('instructions');
mdInstructions = body || '';
if (mdDir) {
supportingFiles = listSupportingFiles(mdDir);
}
@@ -1629,7 +1677,10 @@ export const getSkillSources = (
fields: mdFields,
scope: mdScope,
source: mdSource,
supportingFiles
supportingFiles,
name: matchedDiscovered?.name || skillName,
description: mdDescription,
instructions: mdInstructions,
},
projectMd: { exists: projectExists, path: projectPath },
claudeMd: { exists: claudeExists, path: claudePath },