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:
@@ -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) {
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user