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:
@@ -1,7 +1,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { CodeMirrorEditor } from '@/components/ui/CodeMirrorEditor';
|
||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
import { useSkillsStore, type SkillConfig, type SkillScope, type SupportingFile, type PendingFile } from '@/stores/useSkillsStore';
|
import { useSkillsStore, type SkillConfig, type SkillScope, type SupportingFile, type PendingFile } from '@/stores/useSkillsStore';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
@@ -21,6 +23,8 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
|
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
|
||||||
|
import { PreviewToggleButton } from '@/components/views/PreviewToggleButton';
|
||||||
import { SkillsCatalogPage } from './catalog/SkillsCatalogPage';
|
import { SkillsCatalogPage } from './catalog/SkillsCatalogPage';
|
||||||
import {
|
import {
|
||||||
SKILL_LOCATION_OPTIONS,
|
SKILL_LOCATION_OPTIONS,
|
||||||
@@ -29,6 +33,12 @@ import {
|
|||||||
type SkillLocationValue,
|
type SkillLocationValue,
|
||||||
} from './skillLocations';
|
} from './skillLocations';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { languageByExtension } from '@/lib/codemirror/languageByExtension';
|
||||||
|
import { createFlexokiCodeMirrorTheme } from '@/lib/codemirror/flexokiTheme';
|
||||||
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { EditorView } from '@codemirror/view';
|
||||||
|
import type { Extension } from '@codemirror/state';
|
||||||
|
|
||||||
export interface SkillsPageProps {
|
export interface SkillsPageProps {
|
||||||
view?: 'installed' | 'catalog';
|
view?: 'installed' | 'catalog';
|
||||||
@@ -38,8 +48,57 @@ const SkillsCatalogStandalone: React.FC = () => (
|
|||||||
<SkillsCatalogPage mode="external" onModeChange={() => {}} showModeTabs={false} />
|
<SkillsCatalogPage mode="external" onModeChange={() => {}} showModeTabs={false} />
|
||||||
);
|
);
|
||||||
|
|
||||||
|
type SkillDocumentParseResult = {
|
||||||
|
description: string | null;
|
||||||
|
instructions: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SKILL_DOCUMENT_PATH = 'SKILL.md';
|
||||||
|
const SKILL_EDITOR_HEIGHT_CLASS = 'h-[clamp(320px,58dvh,680px)] min-h-[260px] max-h-[calc(100dvh-220px)]';
|
||||||
|
|
||||||
|
const buildSkillMarkdown = (description: string, instructions: string): string => {
|
||||||
|
const frontmatter = stringifyYaml({ description }).trimEnd();
|
||||||
|
const body = instructions.trimStart();
|
||||||
|
return `---\n${frontmatter}\n---${body ? `\n\n${body}` : '\n'}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> => (
|
||||||
|
typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
);
|
||||||
|
|
||||||
|
const parseSkillMarkdown = (value: string): SkillDocumentParseResult => {
|
||||||
|
const match = value.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)([\s\S]*)$/);
|
||||||
|
if (!match) {
|
||||||
|
return { description: null, instructions: value };
|
||||||
|
}
|
||||||
|
|
||||||
|
let description: string | null = null;
|
||||||
|
try {
|
||||||
|
const frontmatter: unknown = parseYaml(match[1]);
|
||||||
|
if (isRecord(frontmatter)) {
|
||||||
|
const candidate = frontmatter.description;
|
||||||
|
if (typeof candidate === 'string') {
|
||||||
|
description = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
description = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
description,
|
||||||
|
instructions: match[2].replace(/^\r?\n/, ''),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const replaceSkillMarkdownDescription = (value: string, description: string): string => {
|
||||||
|
const parsed = parseSkillMarkdown(value);
|
||||||
|
return buildSkillMarkdown(description, parsed.instructions);
|
||||||
|
};
|
||||||
|
|
||||||
const SkillsInstalledPage: React.FC = () => {
|
const SkillsInstalledPage: React.FC = () => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const { currentTheme } = useThemeSystem();
|
||||||
const {
|
const {
|
||||||
selectedSkillName,
|
selectedSkillName,
|
||||||
getSkillByName,
|
getSkillByName,
|
||||||
@@ -65,6 +124,7 @@ const SkillsInstalledPage: React.FC = () => {
|
|||||||
const selectedSkill = selectedSkillName ? getSkillByName(selectedSkillName) : null;
|
const selectedSkill = selectedSkillName ? getSkillByName(selectedSkillName) : null;
|
||||||
const isNewSkill = Boolean(skillDraft && skillDraft.name === selectedSkillName && !selectedSkill);
|
const isNewSkill = Boolean(skillDraft && skillDraft.name === selectedSkillName && !selectedSkill);
|
||||||
const hasStaleSelection = Boolean(selectedSkillName && !selectedSkill && !skillDraft);
|
const hasStaleSelection = Boolean(selectedSkillName && !selectedSkill && !skillDraft);
|
||||||
|
const isReadOnlySkill = selectedSkill?.path === '<built-in>';
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!hasStaleSelection) {
|
if (!hasStaleSelection) {
|
||||||
@@ -79,6 +139,8 @@ const SkillsInstalledPage: React.FC = () => {
|
|||||||
const [draftSource, setDraftSource] = React.useState<'opencode' | 'agents'>('opencode');
|
const [draftSource, setDraftSource] = React.useState<'opencode' | 'agents'>('opencode');
|
||||||
const [description, setDescription] = React.useState('');
|
const [description, setDescription] = React.useState('');
|
||||||
const [instructions, setInstructions] = React.useState('');
|
const [instructions, setInstructions] = React.useState('');
|
||||||
|
const [skillMarkdown, setSkillMarkdown] = React.useState(() => buildSkillMarkdown('', ''));
|
||||||
|
const [skillEditorMode, setSkillEditorMode] = React.useState<'edit' | 'preview'>('edit');
|
||||||
const [supportingFiles, setSupportingFiles] = React.useState<SupportingFile[]>([]);
|
const [supportingFiles, setSupportingFiles] = React.useState<SupportingFile[]>([]);
|
||||||
const [pendingFiles, setPendingFiles] = React.useState<PendingFile[]>([]);
|
const [pendingFiles, setPendingFiles] = React.useState<PendingFile[]>([]);
|
||||||
const [isSaving, setIsSaving] = React.useState(false);
|
const [isSaving, setIsSaving] = React.useState(false);
|
||||||
@@ -141,11 +203,14 @@ const SkillsInstalledPage: React.FC = () => {
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const loadSkillDetails = async () => {
|
const loadSkillDetails = async () => {
|
||||||
if (isNewSkill && skillDraft) {
|
if (isNewSkill && skillDraft) {
|
||||||
|
const nextDescription = skillDraft.description || '';
|
||||||
|
const nextInstructions = skillDraft.instructions || '';
|
||||||
setDraftName(skillDraft.name || '');
|
setDraftName(skillDraft.name || '');
|
||||||
setDraftScope(skillDraft.scope || 'user');
|
setDraftScope(skillDraft.scope || 'user');
|
||||||
setDraftSource(skillDraft.source === 'agents' ? 'agents' : 'opencode');
|
setDraftSource(skillDraft.source === 'agents' ? 'agents' : 'opencode');
|
||||||
setDescription(skillDraft.description || '');
|
setDescription(nextDescription);
|
||||||
setInstructions(skillDraft.instructions || '');
|
setInstructions(nextInstructions);
|
||||||
|
setSkillMarkdown(buildSkillMarkdown(nextDescription, nextInstructions));
|
||||||
setOriginalDescription('');
|
setOriginalDescription('');
|
||||||
setOriginalInstructions('');
|
setOriginalInstructions('');
|
||||||
setSupportingFiles([]);
|
setSupportingFiles([]);
|
||||||
@@ -156,10 +221,13 @@ const SkillsInstalledPage: React.FC = () => {
|
|||||||
const detail = await getSkillDetail(selectedSkillName);
|
const detail = await getSkillDetail(selectedSkillName);
|
||||||
if (detail) {
|
if (detail) {
|
||||||
const md = detail.sources.md;
|
const md = detail.sources.md;
|
||||||
setDescription(md.description || '');
|
const nextDescription = md.description || '';
|
||||||
setInstructions(md.instructions || '');
|
const nextInstructions = md.instructions || '';
|
||||||
setOriginalDescription(md.description || '');
|
setDescription(nextDescription);
|
||||||
setOriginalInstructions(md.instructions || '');
|
setInstructions(nextInstructions);
|
||||||
|
setSkillMarkdown(buildSkillMarkdown(nextDescription, nextInstructions));
|
||||||
|
setOriginalDescription(nextDescription);
|
||||||
|
setOriginalInstructions(nextInstructions);
|
||||||
setSupportingFiles(md.supportingFiles || []);
|
setSupportingFiles(md.supportingFiles || []);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -173,6 +241,39 @@ const SkillsInstalledPage: React.FC = () => {
|
|||||||
loadSkillDetails();
|
loadSkillDetails();
|
||||||
}, [selectedSkill, isNewSkill, selectedSkillName, skills, skillDraft, getSkillDetail]);
|
}, [selectedSkill, isNewSkill, selectedSkillName, skills, skillDraft, getSkillDetail]);
|
||||||
|
|
||||||
|
const skillEditorExtensions = React.useMemo<Extension[]>(() => {
|
||||||
|
const extensions: Extension[] = [createFlexokiCodeMirrorTheme(currentTheme)];
|
||||||
|
const markdownExtension = languageByExtension(SKILL_DOCUMENT_PATH);
|
||||||
|
if (markdownExtension) {
|
||||||
|
extensions.push(markdownExtension);
|
||||||
|
}
|
||||||
|
extensions.push(EditorView.lineWrapping);
|
||||||
|
return extensions;
|
||||||
|
}, [currentTheme]);
|
||||||
|
|
||||||
|
const supportingFileEditorExtensions = React.useMemo<Extension[]>(() => {
|
||||||
|
const filePath = newFileName.trim() || 'supporting-file.md';
|
||||||
|
const extensions: Extension[] = [createFlexokiCodeMirrorTheme(currentTheme)];
|
||||||
|
const languageExtension = languageByExtension(filePath);
|
||||||
|
if (languageExtension) {
|
||||||
|
extensions.push(languageExtension);
|
||||||
|
}
|
||||||
|
extensions.push(EditorView.lineWrapping);
|
||||||
|
return extensions;
|
||||||
|
}, [currentTheme, newFileName]);
|
||||||
|
|
||||||
|
const handleDescriptionChange = React.useCallback((nextDescription: string) => {
|
||||||
|
setDescription(nextDescription);
|
||||||
|
setSkillMarkdown((current) => replaceSkillMarkdownDescription(current, nextDescription));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSkillMarkdownChange = React.useCallback((nextMarkdown: string) => {
|
||||||
|
setSkillMarkdown(nextMarkdown);
|
||||||
|
const parsed = parseSkillMarkdown(nextMarkdown);
|
||||||
|
setDescription(parsed.description ?? '');
|
||||||
|
setInstructions(parsed.instructions);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
const skillName = isNewSkill ? draftName.trim().replace(/\s+/g, '-').toLowerCase() : selectedSkillName?.trim();
|
const skillName = isNewSkill ? draftName.trim().replace(/\s+/g, '-').toLowerCase() : selectedSkillName?.trim();
|
||||||
|
|
||||||
@@ -469,10 +570,11 @@ const SkillsInstalledPage: React.FC = () => {
|
|||||||
<div className="mt-1.5">
|
<div className="mt-1.5">
|
||||||
<Textarea
|
<Textarea
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
onChange={(e) => handleDescriptionChange(e.target.value)}
|
||||||
placeholder={t('settings.skills.page.field.descriptionPlaceholder')}
|
placeholder={t('settings.skills.page.field.descriptionPlaceholder')}
|
||||||
rows={2}
|
rows={2}
|
||||||
className="w-full resize-none min-h-[60px] max-h-32 bg-transparent"
|
className="w-full resize-none min-h-[60px] max-h-32 bg-transparent"
|
||||||
|
disabled={isReadOnlySkill}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -482,19 +584,44 @@ const SkillsInstalledPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Instructions */}
|
{/* Instructions */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="mb-1 px-1">
|
<div className="mb-1 px-1 flex items-center justify-between gap-2">
|
||||||
<h3 className="typography-ui-header font-medium text-foreground">
|
<h3 className="typography-ui-header font-medium text-foreground">
|
||||||
{t('settings.skills.page.section.instructions')}
|
{t('settings.skills.page.section.instructions')}
|
||||||
</h3>
|
</h3>
|
||||||
|
<PreviewToggleButton
|
||||||
|
currentMode={skillEditorMode === 'preview' ? 'preview' : 'edit'}
|
||||||
|
onToggle={() => setSkillEditorMode((mode) => mode === 'preview' ? 'edit' : 'preview')}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="px-2 pb-2 pt-0">
|
<section className="px-2 pb-2 pt-0">
|
||||||
<Textarea
|
<div
|
||||||
value={instructions}
|
className={cn(
|
||||||
onChange={(e) => setInstructions(e.target.value)}
|
'overflow-hidden rounded-md border border-[var(--surface-subtle)] bg-background',
|
||||||
placeholder={t('settings.skills.page.field.instructionsPlaceholder')}
|
SKILL_EDITOR_HEIGHT_CLASS,
|
||||||
className="min-h-[220px] max-h-[60vh] font-mono typography-meta"
|
)}
|
||||||
/>
|
>
|
||||||
|
{skillEditorMode === 'preview' ? (
|
||||||
|
<ScrollableOverlay outerClassName="h-full" className="h-full">
|
||||||
|
<div className="min-h-full px-4 py-3">
|
||||||
|
<SimpleMarkdownRenderer
|
||||||
|
content={skillMarkdown}
|
||||||
|
className="typography-markdown-body"
|
||||||
|
stripFrontmatter
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ScrollableOverlay>
|
||||||
|
) : (
|
||||||
|
<CodeMirrorEditor
|
||||||
|
value={skillMarkdown}
|
||||||
|
onChange={handleSkillMarkdownChange}
|
||||||
|
readOnly={isReadOnlySkill}
|
||||||
|
extensions={skillEditorExtensions}
|
||||||
|
className="h-full"
|
||||||
|
enableSearch
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -504,7 +631,7 @@ const SkillsInstalledPage: React.FC = () => {
|
|||||||
<h3 className="typography-ui-header font-medium text-foreground">
|
<h3 className="typography-ui-header font-medium text-foreground">
|
||||||
{t('settings.skills.page.section.supportingFiles')}
|
{t('settings.skills.page.section.supportingFiles')}
|
||||||
</h3>
|
</h3>
|
||||||
<Button variant="outline" size="xs" className="!font-normal gap-1" onClick={handleAddFile}>
|
<Button variant="outline" size="xs" className="!font-normal gap-1" onClick={handleAddFile} disabled={isReadOnlySkill}>
|
||||||
<Icon name="add" className="h-3.5 w-3.5" /> {t('settings.skills.page.actions.addFile')}
|
<Icon name="add" className="h-3.5 w-3.5" /> {t('settings.skills.page.actions.addFile')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -536,16 +663,18 @@ const SkillsInstalledPage: React.FC = () => {
|
|||||||
{t('settings.skills.page.badge.pending')}
|
{t('settings.skills.page.badge.pending')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<Button size="sm"
|
{!isReadOnlySkill && (
|
||||||
variant="ghost"
|
<Button size="sm"
|
||||||
className="h-5 w-5 px-0 flex-shrink-0 text-muted-foreground hover:text-[var(--status-error)] opacity-0 group-hover:opacity-100 transition-opacity"
|
variant="ghost"
|
||||||
onClick={(e) => {
|
className="h-5 w-5 px-0 flex-shrink-0 text-muted-foreground hover:text-[var(--status-error)] opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
handleDeleteFile(file.path);
|
e.stopPropagation();
|
||||||
}}
|
handleDeleteFile(file.path);
|
||||||
>
|
}}
|
||||||
<Icon name="delete-bin" className="h-3 w-3" />
|
>
|
||||||
</Button>
|
<Icon name="delete-bin" className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -558,7 +687,7 @@ const SkillsInstalledPage: React.FC = () => {
|
|||||||
<div className="px-2 py-1">
|
<div className="px-2 py-1">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={isSaving || !hasSkillChanges}
|
disabled={isReadOnlySkill || isSaving || !hasSkillChanges}
|
||||||
size="xs"
|
size="xs"
|
||||||
className="!font-normal"
|
className="!font-normal"
|
||||||
>
|
>
|
||||||
@@ -638,13 +767,15 @@ const SkillsInstalledPage: React.FC = () => {
|
|||||||
<label className="typography-ui-label font-medium text-foreground flex-shrink-0">
|
<label className="typography-ui-label font-medium text-foreground flex-shrink-0">
|
||||||
{t('settings.skills.page.fileDialog.field.content')}
|
{t('settings.skills.page.fileDialog.field.content')}
|
||||||
</label>
|
</label>
|
||||||
<Textarea
|
<div className="h-[45vh] min-h-[250px] max-h-[55vh] overflow-hidden rounded-md border border-[var(--surface-subtle)] bg-background">
|
||||||
value={newFileContent}
|
<CodeMirrorEditor
|
||||||
onChange={(e) => setNewFileContent(e.target.value)}
|
value={newFileContent}
|
||||||
placeholder={t('settings.skills.page.fileDialog.field.contentPlaceholder')}
|
onChange={setNewFileContent}
|
||||||
outerClassName="h-[45vh] min-h-[250px] max-h-[55vh]"
|
extensions={supportingFileEditorExtensions}
|
||||||
className="h-full min-h-0 font-mono typography-meta"
|
className="h-full"
|
||||||
/>
|
enableSearch
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ interface SkillsSidebarProps {
|
|||||||
onItemSelect?: () => void;
|
onItemSelect?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BUILT_IN_SKILL_LOCATION = '<built-in>';
|
||||||
|
|
||||||
|
const isBuiltInSkill = (skill: DiscoveredSkill | null | undefined): boolean => skill?.path === BUILT_IN_SKILL_LOCATION;
|
||||||
|
|
||||||
export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) => {
|
export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [renameDialogSkill, setRenameDialogSkill] = React.useState<DiscoveredSkill | null>(null);
|
const [renameDialogSkill, setRenameDialogSkill] = React.useState<DiscoveredSkill | null>(null);
|
||||||
@@ -78,6 +82,7 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteSkill = async (skill: DiscoveredSkill) => {
|
const handleDeleteSkill = async (skill: DiscoveredSkill) => {
|
||||||
|
if (isBuiltInSkill(skill)) return;
|
||||||
setDeleteDialogSkill(skill);
|
setDeleteDialogSkill(skill);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -85,6 +90,10 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
|||||||
if (!deleteDialogSkill) {
|
if (!deleteDialogSkill) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isBuiltInSkill(deleteDialogSkill)) {
|
||||||
|
setDeleteDialogSkill(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsDeletePending(true);
|
setIsDeletePending(true);
|
||||||
const success = await deleteSkill(deleteDialogSkill.name);
|
const success = await deleteSkill(deleteDialogSkill.name);
|
||||||
@@ -98,6 +107,8 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDuplicateSkill = async (skill: DiscoveredSkill) => {
|
const handleDuplicateSkill = async (skill: DiscoveredSkill) => {
|
||||||
|
if (isBuiltInSkill(skill)) return;
|
||||||
|
|
||||||
const baseName = skill.name;
|
const baseName = skill.name;
|
||||||
let copyNumber = 1;
|
let copyNumber = 1;
|
||||||
let newName = `${baseName}-copy`;
|
let newName = `${baseName}-copy`;
|
||||||
@@ -127,12 +138,17 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenRenameDialog = (skill: DiscoveredSkill) => {
|
const handleOpenRenameDialog = (skill: DiscoveredSkill) => {
|
||||||
|
if (isBuiltInSkill(skill)) return;
|
||||||
setRenameNewName(skill.name);
|
setRenameNewName(skill.name);
|
||||||
setRenameDialogSkill(skill);
|
setRenameDialogSkill(skill);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRenameSkill = async () => {
|
const handleRenameSkill = async () => {
|
||||||
if (!renameDialogSkill) return;
|
if (!renameDialogSkill) return;
|
||||||
|
if (isBuiltInSkill(renameDialogSkill)) {
|
||||||
|
setRenameDialogSkill(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const sanitizedName = renameNewName.trim().replace(/\s+/g, '-').toLowerCase();
|
const sanitizedName = renameNewName.trim().replace(/\s+/g, '-').toLowerCase();
|
||||||
|
|
||||||
@@ -443,6 +459,7 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
|
|||||||
? t('settings.skills.sidebar.badge.agents')
|
? t('settings.skills.sidebar.badge.agents')
|
||||||
: t('settings.skills.sidebar.badge.opencode');
|
: t('settings.skills.sidebar.badge.opencode');
|
||||||
const badgeClassName = 'typography-micro text-muted-foreground bg-[var(--surface-muted)] px-1 rounded flex-shrink-0 leading-none pb-px border border-[var(--interactive-border)]/50';
|
const badgeClassName = 'typography-micro text-muted-foreground bg-[var(--surface-muted)] px-1 rounded flex-shrink-0 leading-none pb-px border border-[var(--interactive-border)]/50';
|
||||||
|
const isBuiltIn = isBuiltInSkill(skill);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -471,7 +488,7 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<DropdownMenu open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
{!isBuiltIn ? <DropdownMenu open={isMenuOpen} onOpenChange={onMenuOpenChange}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button size="sm"
|
<Button size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -512,7 +529,7 @@ const SkillListItem: React.FC<SkillListItemProps> = ({
|
|||||||
{t('settings.common.actions.delete')}
|
{t('settings.common.actions.delete')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -53,12 +53,17 @@ import {
|
|||||||
const SETTINGS_NAV_MIN_WIDTH = 176;
|
const SETTINGS_NAV_MIN_WIDTH = 176;
|
||||||
const SETTINGS_NAV_MAX_WIDTH = 280;
|
const SETTINGS_NAV_MAX_WIDTH = 280;
|
||||||
const SETTINGS_NAV_RESIZE_STEP = 8;
|
const SETTINGS_NAV_RESIZE_STEP = 8;
|
||||||
|
const SETTINGS_DETAIL_HISTORY_KEY = '__openchamberSettingsDetail';
|
||||||
|
|
||||||
function clampSettingsNavWidth(width: number): number {
|
function clampSettingsNavWidth(width: number): number {
|
||||||
return Math.min(SETTINGS_NAV_MAX_WIDTH, Math.max(SETTINGS_NAV_MIN_WIDTH, width));
|
return Math.min(SETTINGS_NAV_MAX_WIDTH, Math.max(SETTINGS_NAV_MIN_WIDTH, width));
|
||||||
}
|
}
|
||||||
|
|
||||||
type MobileStage = 'nav' | 'page-sidebar' | 'page-content';
|
type MobileStage = 'nav' | 'page-sidebar' | 'page-content';
|
||||||
|
type SettingsDetailHistoryEntry = {
|
||||||
|
page: SettingsPageSlug;
|
||||||
|
stage: 'page-content';
|
||||||
|
};
|
||||||
|
|
||||||
interface SettingsViewProps {
|
interface SettingsViewProps {
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
@@ -106,6 +111,37 @@ function isPageAvailable(page: SettingsPageMeta, ctx: SettingsRuntimeContext): b
|
|||||||
return page.isAvailable(ctx);
|
return page.isAvailable(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSettingsDetailHistoryEntry(state: unknown): SettingsDetailHistoryEntry | null {
|
||||||
|
if (!isObjectRecord(state)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = state[SETTINGS_DETAIL_HISTORY_KEY];
|
||||||
|
if (!isObjectRecord(detail)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = detail.page;
|
||||||
|
const stage = detail.stage;
|
||||||
|
if (typeof page !== 'string' || stage !== 'page-content') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedPage = resolveSettingsSlug(page);
|
||||||
|
return { page: resolvedPage, stage };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentHistoryState(): Record<string, unknown> {
|
||||||
|
if (typeof window === 'undefined' || !isObjectRecord(window.history.state)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return window.history.state;
|
||||||
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
export function getSettingsNavIcon(slug: SettingsPageSlug): IconName | null {
|
export function getSettingsNavIcon(slug: SettingsPageSlug): IconName | null {
|
||||||
switch (slug) {
|
switch (slug) {
|
||||||
@@ -559,11 +595,84 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
|||||||
}, [isMobile, mobileStage, settingsSlug]);
|
}, [isMobile, mobileStage, settingsSlug]);
|
||||||
|
|
||||||
const showBackButton = isMobile && mobileStage !== 'nav';
|
const showBackButton = isMobile && mobileStage !== 'nav';
|
||||||
|
const backButtonTargetsPageSidebar = isMobile && mobileStage === 'page-content' && settingsSlug === 'skills.installed';
|
||||||
|
const showOpenPageSidebarButton = mobileStage === 'page-content'
|
||||||
|
&& activePageMeta?.kind === 'split'
|
||||||
|
&& !backButtonTargetsPageSidebar;
|
||||||
|
const mobileBackButtonLabel = backButtonTargetsPageSidebar
|
||||||
|
? t('settings.view.actions.back')
|
||||||
|
: showBackButton
|
||||||
|
? t('settings.view.actions.backToSettings')
|
||||||
|
: t('settings.view.actions.closeSettings');
|
||||||
const shortcutKey = getModifierLabel();
|
const shortcutKey = getModifierLabel();
|
||||||
|
|
||||||
|
const pushMobileSplitDetailHistory = React.useCallback((slug: SettingsPageSlug) => {
|
||||||
|
if (typeof window === 'undefined' || runtimeCtx.isVSCode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentDetail = getSettingsDetailHistoryEntry(window.history.state);
|
||||||
|
if (currentDetail?.page === slug && currentDetail.stage === 'page-content') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.history.pushState(
|
||||||
|
{
|
||||||
|
...getCurrentHistoryState(),
|
||||||
|
[SETTINGS_DETAIL_HISTORY_KEY]: { page: slug, stage: 'page-content' },
|
||||||
|
},
|
||||||
|
'',
|
||||||
|
window.location.href,
|
||||||
|
);
|
||||||
|
}, [runtimeCtx.isVSCode]);
|
||||||
|
|
||||||
|
const handleMobilePageSidebarItemSelect = React.useCallback(() => {
|
||||||
|
setMobileStage('page-content');
|
||||||
|
if (settingsSlug === 'skills.installed') {
|
||||||
|
pushMobileSplitDetailHistory(settingsSlug);
|
||||||
|
}
|
||||||
|
}, [pushMobileSplitDetailHistory, settingsSlug]);
|
||||||
|
|
||||||
const handleBack = React.useCallback(() => {
|
const handleBack = React.useCallback(() => {
|
||||||
|
if (backButtonTargetsPageSidebar) {
|
||||||
|
const currentDetail = typeof window !== 'undefined'
|
||||||
|
? getSettingsDetailHistoryEntry(window.history.state)
|
||||||
|
: null;
|
||||||
|
if (currentDetail?.page === settingsSlug && !runtimeCtx.isVSCode) {
|
||||||
|
window.history.back();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMobileStage('page-sidebar');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setMobileStage('nav');
|
setMobileStage('nav');
|
||||||
}, []);
|
}, [backButtonTargetsPageSidebar, runtimeCtx.isVSCode, settingsSlug]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isMobile || runtimeCtx.isVSCode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePopState = (event: PopStateEvent) => {
|
||||||
|
if (settingsSlug !== 'skills.installed') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = getSettingsDetailHistoryEntry(event.state);
|
||||||
|
if (detail?.page === 'skills.installed') {
|
||||||
|
setMobileStage('page-content');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMobileStage((stage) => stage === 'page-content' ? 'page-sidebar' : stage);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('popstate', handlePopState);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('popstate', handlePopState);
|
||||||
|
};
|
||||||
|
}, [isMobile, runtimeCtx.isVSCode, settingsSlug]);
|
||||||
|
|
||||||
const handleOpenPageSidebar = React.useCallback(() => {
|
const handleOpenPageSidebar = React.useCallback(() => {
|
||||||
setMobileStage('page-sidebar');
|
setMobileStage('page-sidebar');
|
||||||
@@ -670,7 +779,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
|||||||
return (
|
return (
|
||||||
<div className={cn('flex-1 min-h-0 overflow-hidden', runtimeCtx.isVSCode ? 'bg-background' : 'bg-sidebar')}>
|
<div className={cn('flex-1 min-h-0 overflow-hidden', runtimeCtx.isVSCode ? 'bg-background' : 'bg-sidebar')}>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
{renderPageSidebar(settingsSlug, { onItemSelect: () => setMobileStage('page-content') })}
|
{renderPageSidebar(settingsSlug, { onItemSelect: handleMobilePageSidebarItemSelect })}
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -724,7 +833,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={showBackButton ? handleBack : onClose}
|
onClick={showBackButton ? handleBack : onClose}
|
||||||
aria-label={showBackButton ? t('settings.view.actions.backToSettings') : t('settings.view.actions.closeSettings')}
|
aria-label={mobileBackButtonLabel}
|
||||||
className="inline-flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg p-2 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
className="inline-flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg p-2 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||||
>
|
>
|
||||||
<Icon name="arrow-left-s" className="h-5 w-5" />
|
<Icon name="arrow-left-s" className="h-5 w-5" />
|
||||||
@@ -736,7 +845,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
|||||||
: (activePageMeta ? getPageTitle(activePageMeta.slug) : t('settings.view.home.title'))}
|
: (activePageMeta ? getPageTitle(activePageMeta.slug) : t('settings.view.home.title'))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mobileStage === 'page-content' && activePageMeta?.kind === 'split' && (
|
{showOpenPageSidebarButton && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleOpenPageSidebar}
|
onClick={handleOpenPageSidebar}
|
||||||
|
|||||||
@@ -1415,7 +1415,7 @@ class OpencodeService {
|
|||||||
for (const item of data as Array<Record<string, unknown>>) {
|
for (const item of data as Array<Record<string, unknown>>) {
|
||||||
const name = typeof item.name === 'string' ? item.name.trim() : '';
|
const name = typeof item.name === 'string' ? item.name.trim() : '';
|
||||||
const location = typeof item.location === 'string' ? item.location : '';
|
const location = typeof item.location === 'string' ? item.location : '';
|
||||||
if (!name || !location || location === '<built-in>') {
|
if (!name || !location) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const skill: { name: string; description?: string; location: string; content?: string } = { name, location };
|
const skill: { name: string; description?: string; location: string; content?: string } = { name, location };
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
AGENT_SCOPE,
|
AGENT_SCOPE,
|
||||||
COMMAND_SCOPE,
|
COMMAND_SCOPE,
|
||||||
discoverSkills,
|
discoverSkills,
|
||||||
|
mergeDiscoveredSkills,
|
||||||
getSkillSources,
|
getSkillSources,
|
||||||
createSkill,
|
createSkill,
|
||||||
updateSkill,
|
updateSkill,
|
||||||
@@ -93,6 +94,15 @@ const parseSkillsCatalogSources = (settings: Record<string, unknown>): SkillsCat
|
|||||||
.filter((value): value is SkillsCatalogSourceConfig => value !== null);
|
.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(
|
export async function handleConfigBridgeMessage(
|
||||||
message: BridgeMessageInput,
|
message: BridgeMessageInput,
|
||||||
ctx: BridgeContext | undefined,
|
ctx: BridgeContext | undefined,
|
||||||
@@ -458,7 +468,7 @@ export async function handleConfigBridgeMessage(
|
|||||||
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
|
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
|
||||||
|
|
||||||
if (!name && normalizedMethod === '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 } };
|
return { id, type, success: true, data: { skills } };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,7 +478,7 @@ export async function handleConfigBridgeMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedMethod === 'GET') {
|
if (normalizedMethod === 'GET') {
|
||||||
const discoveredSkill = ((await deps.fetchOpenCodeSkillsFromApi(ctx, workingDirectory)) || [])
|
const discoveredSkill = (await resolveDiscoveredSkills(deps, ctx, workingDirectory))
|
||||||
.find((skill) => skill.name === skillName);
|
.find((skill) => skill.name === skillName);
|
||||||
const sources = getSkillSources(skillName, workingDirectory, discoveredSkill || null);
|
const sources = getSkillSources(skillName, workingDirectory, discoveredSkill || null);
|
||||||
return {
|
return {
|
||||||
@@ -539,7 +549,7 @@ export async function handleConfigBridgeMessage(
|
|||||||
const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||||
const settings = deps.readSettings(ctx);
|
const settings = deps.readSettings(ctx);
|
||||||
const additionalSources = parseSkillsCatalogSources(settings);
|
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);
|
const data = await getSkillsCatalog(workingDirectory, refresh, additionalSources, installedSkills);
|
||||||
return { id, type, success: true, data };
|
return { id, type, success: true, data };
|
||||||
}
|
}
|
||||||
@@ -623,7 +633,7 @@ export async function handleConfigBridgeMessage(
|
|||||||
return { id, type, success: false, error: 'File path is required' };
|
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);
|
.find((skill) => skill.name === skillName);
|
||||||
const sources = getSkillSources(skillName, workingDirectory, discoveredSkill || null);
|
const sources = getSkillSources(skillName, workingDirectory, discoveredSkill || null);
|
||||||
if (!sources.md.dir) {
|
if (!sources.md.dir) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import * as fs from 'fs';
|
|||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as vscode from 'vscode';
|
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';
|
import type { BridgeContext } from './bridge';
|
||||||
|
|
||||||
const SETTINGS_KEY = 'openchamber.settings';
|
const SETTINGS_KEY = 'openchamber.settings';
|
||||||
@@ -129,9 +129,20 @@ export const fetchOpenCodeSkillsFromApi = async (
|
|||||||
const name = typeof item?.name === 'string' ? item.name.trim() : '';
|
const name = typeof item?.name === 'string' ? item.name.trim() : '';
|
||||||
const location = typeof item?.location === 'string' ? item.location : '';
|
const location = typeof item?.location === 'string' ? item.location : '';
|
||||||
const description = typeof item?.description === 'string' ? item.description : '';
|
const description = typeof item?.description === 'string' ? item.description : '';
|
||||||
|
const content = typeof item?.content === 'string' ? item.content : '';
|
||||||
if (!name || !location) {
|
if (!name || !location) {
|
||||||
return null;
|
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);
|
const inferred = inferSkillScopeAndSourceFromLocation(location, workingDirectory);
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
@@ -139,6 +150,7 @@ export const fetchOpenCodeSkillsFromApi = async (
|
|||||||
scope: inferred.scope,
|
scope: inferred.scope,
|
||||||
source: inferred.source,
|
source: inferred.source,
|
||||||
description,
|
description,
|
||||||
|
content,
|
||||||
} as DiscoveredSkill;
|
} as DiscoveredSkill;
|
||||||
})
|
})
|
||||||
.filter((item): item is DiscoveredSkill => item !== null);
|
.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;
|
scope?: SkillScope | null;
|
||||||
source?: SkillSource | null;
|
source?: SkillSource | null;
|
||||||
supportingFiles: SupportingFile[];
|
supportingFiles: SupportingFile[];
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
instructions?: string;
|
||||||
};
|
};
|
||||||
projectMd?: { exists: boolean; path: string | null };
|
projectMd?: { exists: boolean; path: string | null };
|
||||||
claudeMd?: { exists: boolean; path: string | null };
|
claudeMd?: { exists: boolean; path: string | null };
|
||||||
@@ -1323,6 +1326,34 @@ export type DiscoveredSkill = {
|
|||||||
scope: SkillScope;
|
scope: SkillScope;
|
||||||
source: SkillSource;
|
source: SkillSource;
|
||||||
description?: string;
|
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 = (
|
const addSkillFromMdFile = (
|
||||||
@@ -1562,6 +1593,14 @@ export const getSkillSources = (
|
|||||||
discoveredSkill?: DiscoveredSkill | null
|
discoveredSkill?: DiscoveredSkill | null
|
||||||
): SkillConfigSources => {
|
): SkillConfigSources => {
|
||||||
ensureSkillDirs();
|
ensureSkillDirs();
|
||||||
|
const isReadableFile = (filePath: string | null): boolean => {
|
||||||
|
if (!filePath) return false;
|
||||||
|
try {
|
||||||
|
return fs.statSync(filePath).isFile();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Check all possible locations
|
// Check all possible locations
|
||||||
const projectPath = workingDirectory ? getProjectSkillPath(workingDirectory, skillName) : null;
|
const projectPath = workingDirectory ? getProjectSkillPath(workingDirectory, skillName) : null;
|
||||||
@@ -1579,6 +1618,8 @@ export const getSkillSources = (
|
|||||||
const matchedDiscovered = discoveredSkill?.name === skillName
|
const matchedDiscovered = discoveredSkill?.name === skillName
|
||||||
? discoveredSkill
|
? discoveredSkill
|
||||||
: discoverSkills(workingDirectory).find((skill) => skill.name === skillName);
|
: 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)
|
// Determine which md file to use (priority: project > claude > user)
|
||||||
let mdPath: string | null = null;
|
let mdPath: string | null = null;
|
||||||
@@ -1586,7 +1627,15 @@ export const getSkillSources = (
|
|||||||
let mdSource: SkillSource | null = null;
|
let mdSource: SkillSource | null = null;
|
||||||
let mdDir: string | 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;
|
mdPath = projectPath;
|
||||||
mdScope = SKILL_SCOPE.PROJECT;
|
mdScope = SKILL_SCOPE.PROJECT;
|
||||||
mdSource = 'opencode';
|
mdSource = 'opencode';
|
||||||
@@ -1601,21 +1650,20 @@ export const getSkillSources = (
|
|||||||
mdScope = SKILL_SCOPE.USER;
|
mdScope = SKILL_SCOPE.USER;
|
||||||
mdSource = 'opencode';
|
mdSource = 'opencode';
|
||||||
mdDir = userDir;
|
mdDir = userDir;
|
||||||
} else if (matchedDiscovered?.path) {
|
|
||||||
mdPath = matchedDiscovered.path;
|
|
||||||
mdScope = matchedDiscovered.scope;
|
|
||||||
mdSource = matchedDiscovered.source;
|
|
||||||
mdDir = path.dirname(matchedDiscovered.path);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const mdExists = !!mdPath;
|
const mdExists = isBuiltInDiscovered || !!mdPath;
|
||||||
let mdFields: string[] = [];
|
let mdFields: string[] = isBuiltInDiscovered ? ['description', 'instructions'] : [];
|
||||||
let supportingFiles: SupportingFile[] = [];
|
let supportingFiles: SupportingFile[] = [];
|
||||||
|
let mdDescription = typeof matchedDiscovered?.description === 'string' ? matchedDiscovered.description : '';
|
||||||
|
let mdInstructions = isBuiltInDiscovered && typeof matchedDiscovered?.content === 'string' ? matchedDiscovered.content : '';
|
||||||
|
|
||||||
if (mdExists && mdPath) {
|
if (mdExists && mdPath) {
|
||||||
const { frontmatter, body } = parseMdFile(mdPath);
|
const { frontmatter, body } = parseMdFile(mdPath);
|
||||||
mdFields = Object.keys(frontmatter);
|
mdFields = Object.keys(frontmatter);
|
||||||
|
mdDescription = typeof frontmatter.description === 'string' ? frontmatter.description : '';
|
||||||
if (body) mdFields.push('instructions');
|
if (body) mdFields.push('instructions');
|
||||||
|
mdInstructions = body || '';
|
||||||
if (mdDir) {
|
if (mdDir) {
|
||||||
supportingFiles = listSupportingFiles(mdDir);
|
supportingFiles = listSupportingFiles(mdDir);
|
||||||
}
|
}
|
||||||
@@ -1629,7 +1677,10 @@ export const getSkillSources = (
|
|||||||
fields: mdFields,
|
fields: mdFields,
|
||||||
scope: mdScope,
|
scope: mdScope,
|
||||||
source: mdSource,
|
source: mdSource,
|
||||||
supportingFiles
|
supportingFiles,
|
||||||
|
name: matchedDiscovered?.name || skillName,
|
||||||
|
description: mdDescription,
|
||||||
|
instructions: mdInstructions,
|
||||||
},
|
},
|
||||||
projectMd: { exists: projectExists, path: projectPath },
|
projectMd: { exists: projectExists, path: projectPath },
|
||||||
claudeMd: { exists: claudeExists, path: claudePath },
|
claudeMd: { exists: claudeExists, path: claudePath },
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
|||||||
const {
|
const {
|
||||||
getSkillSources,
|
getSkillSources,
|
||||||
discoverSkills,
|
discoverSkills,
|
||||||
|
mergeDiscoveredSkills,
|
||||||
createSkill,
|
createSkill,
|
||||||
updateSkill,
|
updateSkill,
|
||||||
deleteSkill,
|
deleteSkill,
|
||||||
@@ -201,6 +202,7 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
|||||||
getOpenCodePort,
|
getOpenCodePort,
|
||||||
getSkillSources,
|
getSkillSources,
|
||||||
discoverSkills,
|
discoverSkills,
|
||||||
|
mergeDiscoveredSkills,
|
||||||
createSkill,
|
createSkill,
|
||||||
updateSkill,
|
updateSkill,
|
||||||
deleteSkill,
|
deleteSkill,
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export {
|
|||||||
getSkillSources,
|
getSkillSources,
|
||||||
getSkillScope,
|
getSkillScope,
|
||||||
discoverSkills,
|
discoverSkills,
|
||||||
|
mergeDiscoveredSkills,
|
||||||
createSkill,
|
createSkill,
|
||||||
updateSkill,
|
updateSkill,
|
||||||
deleteSkill,
|
deleteSkill,
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export const registerSkillRoutes = (app, dependencies) => {
|
|||||||
getOpenCodeAuthHeaders,
|
getOpenCodeAuthHeaders,
|
||||||
getOpenCodePort,
|
getOpenCodePort,
|
||||||
getSkillSources,
|
getSkillSources,
|
||||||
|
discoverSkills,
|
||||||
|
mergeDiscoveredSkills,
|
||||||
createSkill,
|
createSkill,
|
||||||
updateSkill,
|
updateSkill,
|
||||||
deleteSkill,
|
deleteSkill,
|
||||||
@@ -139,17 +141,32 @@ export const registerSkillRoutes = (app, dependencies) => {
|
|||||||
const name = typeof item?.name === 'string' ? item.name.trim() : '';
|
const name = typeof item?.name === 'string' ? item.name.trim() : '';
|
||||||
const location = typeof item?.location === 'string' ? item.location : '';
|
const location = typeof item?.location === 'string' ? item.location : '';
|
||||||
const description = typeof item?.description === 'string' ? item.description : '';
|
const description = typeof item?.description === 'string' ? item.description : '';
|
||||||
if (!name || !location || location === '<built-in>') {
|
const content = typeof item?.content === 'string' ? item.content : '';
|
||||||
|
if (!name || !location) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (location === '<built-in>') {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
path: location,
|
||||||
|
scope: SKILL_SCOPE.USER,
|
||||||
|
source: 'opencode',
|
||||||
|
description,
|
||||||
|
content,
|
||||||
|
};
|
||||||
|
}
|
||||||
const inferred = inferSkillScopeAndSourceFromPath(location, workingDirectory);
|
const inferred = inferSkillScopeAndSourceFromPath(location, workingDirectory);
|
||||||
return {
|
const skill = {
|
||||||
name,
|
name,
|
||||||
path: location,
|
path: location,
|
||||||
scope: inferred.scope,
|
scope: inferred.scope,
|
||||||
source: inferred.source,
|
source: inferred.source,
|
||||||
description,
|
description,
|
||||||
};
|
};
|
||||||
|
if (content) {
|
||||||
|
skill.content = content;
|
||||||
|
}
|
||||||
|
return skill;
|
||||||
})
|
})
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -189,7 +206,9 @@ export const registerSkillRoutes = (app, dependencies) => {
|
|||||||
if (error) {
|
if (error) {
|
||||||
return res.status(400).json({ error });
|
return res.status(400).json({ error });
|
||||||
}
|
}
|
||||||
const skills = await fetchOpenCodeDiscoveredSkills(directory);
|
const openCodeSkills = await fetchOpenCodeDiscoveredSkills(directory);
|
||||||
|
const localSkills = discoverSkills(directory);
|
||||||
|
const skills = mergeDiscoveredSkills(openCodeSkills, localSkills);
|
||||||
|
|
||||||
const enrichedSkills = skills.map((skill) => {
|
const enrichedSkills = skills.map((skill) => {
|
||||||
const sources = getSkillSources(skill.name, directory, skill);
|
const sources = getSkillSources(skill.name, directory, skill);
|
||||||
@@ -271,8 +290,11 @@ export const registerSkillRoutes = (app, dependencies) => {
|
|||||||
return res.status(404).json({ ok: false, error: { kind: 'invalidSource', message: 'Unknown source' } });
|
return res.status(404).json({ ok: false, error: { kind: 'invalidSource', message: 'Unknown source' } });
|
||||||
}
|
}
|
||||||
|
|
||||||
const discovered = await fetchOpenCodeDiscoveredSkills(directory);
|
const resolvedDiscovered = mergeDiscoveredSkills(
|
||||||
const installedByName = new Map(discovered.map((s) => [s.name, s]));
|
await fetchOpenCodeDiscoveredSkills(directory),
|
||||||
|
discoverSkills(directory),
|
||||||
|
);
|
||||||
|
const installedByName = new Map(resolvedDiscovered.map((s) => [s.name, s]));
|
||||||
|
|
||||||
if (src.sourceType === 'clawdhub' || isClawdHubSource(src.source)) {
|
if (src.sourceType === 'clawdhub' || isClawdHubSource(src.source)) {
|
||||||
const scanned = await scanClawdHubPage({ cursor: cursor || null });
|
const scanned = await scanClawdHubPage({ cursor: cursor || null });
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import {
|
|||||||
findWorktreeRoot,
|
findWorktreeRoot,
|
||||||
} from './shared.js';
|
} from './shared.js';
|
||||||
|
|
||||||
|
const BUILT_IN_SKILL_LOCATION = '<built-in>';
|
||||||
|
|
||||||
function ensureProjectSkillDir(workingDirectory) {
|
function ensureProjectSkillDir(workingDirectory) {
|
||||||
const projectSkillDir = path.join(workingDirectory, '.opencode', 'skills');
|
const projectSkillDir = path.join(workingDirectory, '.opencode', 'skills');
|
||||||
if (!fs.existsSync(projectSkillDir)) {
|
if (!fs.existsSync(projectSkillDir)) {
|
||||||
@@ -236,7 +238,39 @@ function discoverSkills(workingDirectory) {
|
|||||||
return Array.from(skills.values());
|
return Array.from(skills.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeDiscoveredSkills(primarySkills = [], fallbackSkills = []) {
|
||||||
|
const merged = [];
|
||||||
|
const seenNames = new Set();
|
||||||
|
|
||||||
|
const appendSkill = (skill) => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
function getSkillSources(skillName, workingDirectory, discoveredSkill = null) {
|
function getSkillSources(skillName, workingDirectory, discoveredSkill = null) {
|
||||||
|
const isReadableFile = (filePath) => {
|
||||||
|
if (!filePath) return false;
|
||||||
|
try {
|
||||||
|
return fs.statSync(filePath).isFile();
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const projectPath = workingDirectory ? getProjectSkillPath(workingDirectory, skillName) : null;
|
const projectPath = workingDirectory ? getProjectSkillPath(workingDirectory, skillName) : null;
|
||||||
const projectExists = projectPath && fs.existsSync(projectPath);
|
const projectExists = projectPath && fs.existsSync(projectPath);
|
||||||
const projectDir = projectExists ? path.dirname(projectPath) : null;
|
const projectDir = projectExists ? path.dirname(projectPath) : null;
|
||||||
@@ -259,17 +293,33 @@ function getSkillSources(skillName, workingDirectory, discoveredSkill = null) {
|
|||||||
const matchedDiscovered = discoveredSkill && discoveredSkill.name === skillName
|
const matchedDiscovered = discoveredSkill && discoveredSkill.name === skillName
|
||||||
? discoveredSkill
|
? discoveredSkill
|
||||||
: discoverSkills(workingDirectory).find((skill) => skill.name === skillName);
|
: discoverSkills(workingDirectory).find((skill) => skill.name === skillName);
|
||||||
|
const discoveredDescription =
|
||||||
|
matchedDiscovered && typeof matchedDiscovered.description === 'string'
|
||||||
|
? matchedDiscovered.description
|
||||||
|
: '';
|
||||||
|
const discoveredContent =
|
||||||
|
matchedDiscovered && typeof matchedDiscovered.content === 'string'
|
||||||
|
? matchedDiscovered.content
|
||||||
|
: '';
|
||||||
|
const discoveredPath =
|
||||||
|
matchedDiscovered && typeof matchedDiscovered.path === 'string'
|
||||||
|
? matchedDiscovered.path
|
||||||
|
: null;
|
||||||
|
const isBuiltInDiscovered = discoveredPath === BUILT_IN_SKILL_LOCATION;
|
||||||
|
|
||||||
let mdPath = null;
|
let mdPath = null;
|
||||||
let mdScope = null;
|
let mdScope = null;
|
||||||
let mdSource = null;
|
let mdSource = null;
|
||||||
let mdDir = null;
|
let mdDir = null;
|
||||||
|
|
||||||
if (matchedDiscovered?.path) {
|
if (isBuiltInDiscovered) {
|
||||||
mdPath = matchedDiscovered.path;
|
mdScope = matchedDiscovered.scope || SKILL_SCOPE.USER;
|
||||||
|
mdSource = matchedDiscovered.source || 'opencode';
|
||||||
|
} else if (discoveredPath) {
|
||||||
|
mdPath = discoveredPath;
|
||||||
mdScope = matchedDiscovered.scope || null;
|
mdScope = matchedDiscovered.scope || null;
|
||||||
mdSource = matchedDiscovered.source || null;
|
mdSource = matchedDiscovered.source || null;
|
||||||
mdDir = path.dirname(matchedDiscovered.path);
|
mdDir = isReadableFile(discoveredPath) ? path.dirname(discoveredPath) : null;
|
||||||
} else if (projectExists) {
|
} else if (projectExists) {
|
||||||
mdPath = projectPath;
|
mdPath = projectPath;
|
||||||
mdScope = SKILL_SCOPE.PROJECT;
|
mdScope = SKILL_SCOPE.PROJECT;
|
||||||
@@ -297,10 +347,12 @@ function getSkillSources(skillName, workingDirectory, discoveredSkill = null) {
|
|||||||
mdDir = userAgentsDir;
|
mdDir = userAgentsDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mdExists = !!mdPath && fs.existsSync(mdPath);
|
const mdExists = isBuiltInDiscovered || isReadableFile(mdPath);
|
||||||
if (!mdExists) {
|
if (!mdExists) {
|
||||||
mdPath = null;
|
mdPath = null;
|
||||||
mdDir = null;
|
mdDir = null;
|
||||||
|
mdScope = null;
|
||||||
|
mdSource = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sources = {
|
const sources = {
|
||||||
@@ -310,8 +362,11 @@ function getSkillSources(skillName, workingDirectory, discoveredSkill = null) {
|
|||||||
dir: mdDir,
|
dir: mdDir,
|
||||||
scope: mdScope,
|
scope: mdScope,
|
||||||
source: mdSource,
|
source: mdSource,
|
||||||
fields: [],
|
fields: isBuiltInDiscovered ? ['description', 'instructions'] : [],
|
||||||
supportingFiles: []
|
supportingFiles: [],
|
||||||
|
name: matchedDiscovered?.name || skillName,
|
||||||
|
description: discoveredDescription,
|
||||||
|
instructions: isBuiltInDiscovered ? discoveredContent : ''
|
||||||
},
|
},
|
||||||
projectMd: {
|
projectMd: {
|
||||||
exists: projectExists,
|
exists: projectExists,
|
||||||
@@ -542,6 +597,7 @@ export {
|
|||||||
getSkillScope,
|
getSkillScope,
|
||||||
getSkillWritePath,
|
getSkillWritePath,
|
||||||
discoverSkills,
|
discoverSkills,
|
||||||
|
mergeDiscoveredSkills,
|
||||||
createSkill,
|
createSkill,
|
||||||
updateSkill,
|
updateSkill,
|
||||||
deleteSkill,
|
deleteSkill,
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import fsPromises from 'fs/promises';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
import { getSkillSources, mergeDiscoveredSkills } from './skills.js';
|
||||||
|
|
||||||
|
describe('skills', () => {
|
||||||
|
it('merges locally discovered skills missing from OpenCode live discovery', () => {
|
||||||
|
const merged = mergeDiscoveredSkills(
|
||||||
|
[
|
||||||
|
{ name: 'existing-opencode-skill', path: '/home/jkker/.config/opencode/skills/existing-opencode-skill/SKILL.md', source: 'opencode' },
|
||||||
|
{ name: 'existing-agent-skill', path: '/home/jkker/.agents/skills/existing-agent-skill/SKILL.md', source: 'agents' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ name: 'existing-agent-skill', path: '/home/jkker/.agents/skills/existing-agent-skill/SKILL.md', source: 'agents' },
|
||||||
|
{ name: 'new-agent-skill', path: '/home/jkker/.agents/skills/new-agent-skill/SKILL.md', source: 'agents' },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(merged.map((skill) => skill.name)).toEqual([
|
||||||
|
'existing-opencode-skill',
|
||||||
|
'existing-agent-skill',
|
||||||
|
'new-agent-skill',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves built-in OpenCode skill content without parsing virtual locations as files', () => {
|
||||||
|
const sources = getSkillSources(
|
||||||
|
'customize-opencode',
|
||||||
|
'/tmp/openchamber-skills-test-missing-project',
|
||||||
|
{
|
||||||
|
name: 'customize-opencode',
|
||||||
|
path: '<built-in>',
|
||||||
|
scope: 'user',
|
||||||
|
source: 'opencode',
|
||||||
|
description: 'Customize opencode',
|
||||||
|
content: '# Customizing opencode\n\nUse this skill when updating config.',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sources.md.exists).toBe(true);
|
||||||
|
expect(sources.md.path).toBe(null);
|
||||||
|
expect(sources.md.dir).toBe(null);
|
||||||
|
expect(sources.md.scope).toBe('user');
|
||||||
|
expect(sources.md.source).toBe('opencode');
|
||||||
|
expect(sources.md.description).toBe('Customize opencode');
|
||||||
|
expect(sources.md.instructions).toBe('# Customizing opencode\n\nUse this skill when updating config.');
|
||||||
|
expect(sources.md.fields).toEqual(['description', 'instructions']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears file metadata when a discovered skill path is unreadable', () => {
|
||||||
|
const missingPath = path.join(os.tmpdir(), 'openchamber-skills-test-missing-file', 'SKILL.md');
|
||||||
|
const sources = getSkillSources(
|
||||||
|
'missing-agent-skill',
|
||||||
|
'/tmp/openchamber-skills-test-missing-project',
|
||||||
|
{
|
||||||
|
name: 'missing-agent-skill',
|
||||||
|
path: missingPath,
|
||||||
|
scope: 'user',
|
||||||
|
source: 'agents',
|
||||||
|
description: 'Missing skill',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sources.md.exists).toBe(false);
|
||||||
|
expect(sources.md.path).toBe(null);
|
||||||
|
expect(sources.md.dir).toBe(null);
|
||||||
|
expect(sources.md.scope).toBe(null);
|
||||||
|
expect(sources.md.source).toBe(null);
|
||||||
|
expect(sources.md.description).toBe('Missing skill');
|
||||||
|
expect(sources.md.instructions).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enriches discovered skills when their location is a real markdown file', async () => {
|
||||||
|
const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-skills-'));
|
||||||
|
const skillDir = path.join(tempRoot, 'example-skill');
|
||||||
|
const skillPath = path.join(skillDir, 'SKILL.md');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fsPromises.mkdir(skillDir, { recursive: true });
|
||||||
|
await fsPromises.writeFile(
|
||||||
|
skillPath,
|
||||||
|
[
|
||||||
|
'---',
|
||||||
|
'name: example-skill',
|
||||||
|
'description: Example from agents',
|
||||||
|
'---',
|
||||||
|
'',
|
||||||
|
'Use this skill for examples.',
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const sources = getSkillSources('example-skill', tempRoot, {
|
||||||
|
name: 'example-skill',
|
||||||
|
path: skillPath,
|
||||||
|
scope: 'user',
|
||||||
|
source: 'agents',
|
||||||
|
description: 'Fallback description',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sources.md.exists).toBe(true);
|
||||||
|
expect(sources.md.path).toBe(skillPath);
|
||||||
|
expect(sources.md.scope).toBe('user');
|
||||||
|
expect(sources.md.source).toBe('agents');
|
||||||
|
expect(sources.md.description).toBe('Example from agents');
|
||||||
|
expect(sources.md.instructions).toBe('Use this skill for examples.');
|
||||||
|
} finally {
|
||||||
|
await fsPromises.rm(tempRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user