feat(skills-catalog): implement caching, curated sources, git operations, and skill installation

This commit is contained in:
Bohdan Triapitsyn
2025-12-30 21:03:25 +02:00
parent 92794d1810
commit a236dc81c9
28 changed files with 4790 additions and 7 deletions
@@ -82,6 +82,7 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
// Set draft and open the page for editing
setAgentDraft({ name: newName, scope: 'user' });
setSelectedAgent(newName);
onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
@@ -81,6 +81,7 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
// Set draft and open the page for editing
setCommandDraft({ name: newName, scope: 'user' });
setSelectedCommand(newName);
onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
@@ -21,6 +21,8 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { ButtonLarge } from '@/components/ui/button-large';
import { AnimatedTabs } from '@/components/ui/animated-tabs';
import { SkillsCatalogPage } from './catalog/SkillsCatalogPage';
export const SkillsPage: React.FC = () => {
const {
@@ -37,6 +39,37 @@ export const SkillsPage: React.FC = () => {
const selectedSkill = selectedSkillName ? getSkillByName(selectedSkillName) : null;
const isNewSkill = Boolean(skillDraft && skillDraft.name === selectedSkillName && !selectedSkill);
const hasStaleSelection = Boolean(selectedSkillName && !selectedSkill && !skillDraft);
type SkillsMode = 'manual' | 'external';
const [mode, setMode] = React.useState<SkillsMode>('manual');
React.useEffect(() => {
if (!isNewSkill && mode !== 'manual') {
setMode('manual');
}
}, [isNewSkill, mode]);
React.useEffect(() => {
if (!hasStaleSelection) {
return;
}
// Clear persisted selection if it points to a non-existent skill.
setSelectedSkill(null);
}, [hasStaleSelection, setSelectedSkill]);
const modeTabs = isNewSkill ? (
<AnimatedTabs
tabs={[
{ value: 'manual', label: 'Manual' },
{ value: 'external', label: 'External' },
]}
value={mode}
onValueChange={setMode}
animate={false}
/>
) : null;
const [draftName, setDraftName] = React.useState('');
const [draftScope, setDraftScope] = React.useState<SkillScope>('user');
@@ -71,6 +104,10 @@ export const SkillsPage: React.FC = () => {
// Load skill details when selection changes
React.useEffect(() => {
if (mode === 'external') {
return;
}
const loadSkillDetails = async () => {
if (isNewSkill && skillDraft) {
// Prefill from draft (for new or duplicated skills)
@@ -104,7 +141,7 @@ export const SkillsPage: React.FC = () => {
};
loadSkillDetails();
}, [selectedSkill, isNewSkill, selectedSkillName, skills, skillDraft, getSkillDetail]);
}, [selectedSkill, isNewSkill, selectedSkillName, skills, skillDraft, getSkillDetail, mode]);
const handleSave = async () => {
const skillName = isNewSkill ? draftName.trim().replace(/\s+/g, '-').toLowerCase() : selectedSkillName?.trim();
@@ -297,8 +334,13 @@ export const SkillsPage: React.FC = () => {
}
};
// Show empty state only when nothing is selected AND no draft
if (!selectedSkillName && !skillDraft) {
if (isNewSkill && mode === 'external') {
return <SkillsCatalogPage mode={mode} onModeChange={setMode} />;
}
// Show empty state when nothing is selected or selection is stale
if ((!selectedSkillName && !skillDraft) || hasStaleSelection) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center text-muted-foreground">
@@ -322,6 +364,8 @@ export const SkillsPage: React.FC = () => {
return (
<ScrollableOverlay outerClassName="h-full" className="mx-auto max-w-3xl space-y-6 p-6">
{isNewSkill ? modeTabs : null}
{/* Header */}
<div className="space-y-1">
<h1 className="typography-ui-header font-semibold text-lg">
@@ -82,6 +82,7 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
// Set draft and open the page for editing
setSkillDraft({ name: newName, scope: 'user', description: '' });
setSelectedSkill(newName);
onItemSelect?.();
if (isMobile) {
setSidebarOpen(false);
@@ -0,0 +1,333 @@
import React from 'react';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from '@/components/ui/select';
import { RiGitRepositoryLine } from '@remixicon/react';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { getDesktopSettings, isDesktopRuntime, isVSCodeRuntime } from '@/lib/desktop';
import { updateDesktopSettings } from '@/lib/persistence';
import type { DesktopSettings, SkillCatalogConfig } from '@/lib/desktop';
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
const generateCatalogId = () => `custom:${Date.now()}-${Math.random().toString(16).slice(2)}`;
const guessLabelFromSource = (value: string) => {
const trimmed = value.trim();
const ssh = trimmed.match(/^git@github\.com:([^/\s]+)\/([^\s#]+)$/i);
if (ssh) {
return `${ssh[1]}/${ssh[2].replace(/\.git$/i, '')}`;
}
const https = trimmed.match(/^https?:\/\/github\.com\/([^/\s]+)\/([^\s#]+)$/i);
if (https) {
return `${https[1]}/${https[2].replace(/\.git$/i, '')}`;
}
const shorthand = trimmed.match(/^([^/\s]+)\/([^/\s]+)(?:\/.+)?$/);
if (shorthand) {
return `${shorthand[1]}/${shorthand[2].replace(/\.git$/i, '')}`;
}
return trimmed;
};
type IdentityOption = { id: string; name: string };
const loadSettings = async (): Promise<DesktopSettings | null> => {
try {
if (isDesktopRuntime()) {
return await getDesktopSettings();
}
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
if (runtimeSettings) {
const result = await runtimeSettings.load();
return (result?.settings || {}) as DesktopSettings;
}
const response = await fetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
return null;
}
return (await response.json().catch(() => null)) as DesktopSettings | null;
} catch {
return null;
}
};
interface AddCatalogDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const AddCatalogDialog: React.FC<AddCatalogDialogProps> = ({ open, onOpenChange }) => {
const { scanRepo, loadCatalog, isScanning } = useSkillsCatalogStore();
const [label, setLabel] = React.useState('');
const [source, setSource] = React.useState('');
const [subpath, setSubpath] = React.useState('');
const [existingCatalogs, setExistingCatalogs] = React.useState<SkillCatalogConfig[]>([]);
const [scanCount, setScanCount] = React.useState<number | null>(null);
const [scanOk, setScanOk] = React.useState(false);
const [identityOptions, setIdentityOptions] = React.useState<IdentityOption[]>([]);
const [gitIdentityId, setGitIdentityId] = React.useState<string | null>(null);
React.useEffect(() => {
if (!open) return;
setLabel('');
setSource('');
setSubpath('');
setScanCount(null);
setScanOk(false);
setIdentityOptions([]);
setGitIdentityId(null);
void (async () => {
const settings = await loadSettings();
const catalogs = Array.isArray(settings?.skillCatalogs) ? settings?.skillCatalogs : [];
setExistingCatalogs(catalogs || []);
})();
}, [open]);
const isDuplicate = React.useMemo(() => {
const normalizedSource = source.trim();
const normalizedSubpath = subpath.trim();
return existingCatalogs.some((c) => {
const s = (c.source || '').trim();
const sp = (c.subpath || '').trim();
return s === normalizedSource && sp === normalizedSubpath;
});
}, [existingCatalogs, source, subpath]);
const handleScan = async () => {
const trimmedSource = source.trim();
if (!trimmedSource) {
toast.error('Repository source is required');
return;
}
if (!label.trim()) {
setLabel(guessLabelFromSource(trimmedSource));
}
setScanOk(false);
setScanCount(null);
const result = await scanRepo({
source: trimmedSource,
subpath: subpath.trim() || undefined,
gitIdentityId: gitIdentityId || undefined,
});
if (!result.ok) {
if (result.error?.kind === 'authRequired') {
if (isVSCodeRuntime()) {
toast.error('Private repositories are not supported in VS Code yet');
return;
}
const ids = (result.error.identities || []) as IdentityOption[];
setIdentityOptions(ids);
if (!gitIdentityId && ids.length > 0) {
setGitIdentityId(ids[0].id);
}
toast.error('Authentication required. Select a Git identity and scan again.');
return;
}
toast.error(result.error?.message || 'Failed to scan repository');
return;
}
const count = result.items?.length || 0;
setScanCount(count);
if (count === 0) {
toast.error('No skills found in this repository');
setScanOk(false);
return;
}
setIdentityOptions([]);
setScanOk(true);
toast.success(`Found ${count} skill(s)`);
};
const handleAdd = async () => {
const trimmedLabel = label.trim();
const trimmedSource = source.trim();
const trimmedSubpath = subpath.trim();
if (!trimmedLabel) {
toast.error('Catalog name is required');
return;
}
if (!trimmedSource) {
toast.error('Repository source is required');
return;
}
if (!scanOk) {
toast.error('Scan the repository before adding this catalog');
return;
}
if (isDuplicate) {
toast.error('This catalog already exists');
return;
}
const next: SkillCatalogConfig = {
id: generateCatalogId(),
label: trimmedLabel,
source: trimmedSource,
...(trimmedSubpath ? { subpath: trimmedSubpath } : {}),
...(gitIdentityId ? { gitIdentityId } : {}),
};
const updated = [...existingCatalogs, next];
try {
await updateDesktopSettings({ skillCatalogs: updated });
setExistingCatalogs(updated);
toast.success('Catalog added');
await loadCatalog({ refresh: true });
onOpenChange(false);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to save catalog');
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>Add skills catalog</DialogTitle>
<DialogDescription>
Add a Git repository as a new catalog source. OpenChamber will scan it for folders containing <code className="font-mono">SKILL.md</code>.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">Catalog name</label>
<Input value={label} onChange={(e) => setLabel(e.target.value)} placeholder="e.g. Team Skills" />
</div>
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">Repository</label>
<Input
value={source}
onChange={(e) => {
setSource(e.target.value);
setScanOk(false);
setScanCount(null);
}}
placeholder="owner/repo or git@github.com:owner/repo.git"
/>
<p className="typography-micro text-muted-foreground">
Public repos work everywhere. Private repos require SSH identity (Desktop/Web only).
</p>
</div>
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">Optional subpath</label>
<Input
value={subpath}
onChange={(e) => {
setSubpath(e.target.value);
setScanOk(false);
setScanCount(null);
}}
placeholder="e.g. skills"
/>
</div>
{identityOptions.length > 0 && !isVSCodeRuntime() ? (
<div className="rounded-lg border bg-muted/20 px-3 py-2">
<div className="typography-ui-label font-medium text-foreground">Authentication required</div>
<div className="typography-meta text-muted-foreground mt-1">
Select a Git identity (SSH key) that can access this repository.
</div>
<div className="mt-2">
<Select value={gitIdentityId || ''} onValueChange={(v) => setGitIdentityId(v)}>
<SelectTrigger className="!h-9 w-full justify-between">
<span>{identityOptions.find((i) => i.id === gitIdentityId)?.name || 'Choose identity'}</span>
</SelectTrigger>
<SelectContent align="start">
{identityOptions.map((id) => (
<SelectItem key={id.id} value={id.id} className="pr-2 [&>span:first-child]:hidden">
{id.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="typography-micro text-muted-foreground mt-2">
Configure identities in Settings Git Identities.
</div>
</div>
) : null}
{scanCount !== null ? (
<div className="typography-meta text-muted-foreground">
Scan result: {scanCount} skill(s) found
</div>
) : null}
{isDuplicate ? (
<div className="typography-meta text-muted-foreground">
This catalog is already added.
</div>
) : null}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
variant="outline"
onClick={() => void handleScan()}
disabled={isScanning || !source.trim()}
className="gap-2"
>
<RiGitRepositoryLine className="h-4 w-4" />
{isScanning ? 'Scanning…' : 'Scan'}
</Button>
<Button
variant="default"
onClick={() => void handleAdd()}
disabled={!scanOk || isDuplicate || !label.trim() || !source.trim()}
>
Add catalog
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,126 @@
import React from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { ButtonLarge } from '@/components/ui/button-large';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from '@/components/ui/select';
export type SkillConflict = {
skillName: string;
scope: 'user' | 'project';
};
export type ConflictDecision = 'skip' | 'overwrite';
interface InstallConflictsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
conflicts: SkillConflict[];
onConfirm: (decisions: Record<string, ConflictDecision>) => void;
}
export const InstallConflictsDialog: React.FC<InstallConflictsDialogProps> = ({
open,
onOpenChange,
conflicts,
onConfirm,
}) => {
const [decisions, setDecisions] = React.useState<Record<string, ConflictDecision>>({});
React.useEffect(() => {
if (!open) return;
const initial: Record<string, ConflictDecision> = {};
for (const conflict of conflicts) {
initial[conflict.skillName] = 'skip';
}
setDecisions(initial);
}, [open, conflicts]);
const setAll = (decision: ConflictDecision) => {
const next: Record<string, ConflictDecision> = {};
for (const conflict of conflicts) {
next[conflict.skillName] = decision;
}
setDecisions(next);
};
const canConfirm = conflicts.length > 0 && conflicts.every((c) => decisions[c.skillName]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Skills already exist</DialogTitle>
<DialogDescription>
Some selected skills are already installed in this scope. Choose whether to skip or overwrite them.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<span className="typography-meta text-muted-foreground">{conflicts.length} conflict(s)</span>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setAll('skip')}>Skip all</Button>
<Button variant="outline" size="sm" onClick={() => setAll('overwrite')}>Overwrite all</Button>
</div>
</div>
<div className="space-y-2">
{conflicts.map((conflict) => (
<div
key={conflict.skillName}
className="flex items-center justify-between gap-3 rounded-lg border bg-muted/20 px-3 py-2"
>
<div className="min-w-0">
<div className="typography-ui-label truncate">{conflict.skillName}</div>
<div className="typography-micro text-muted-foreground">Installed in {conflict.scope} scope</div>
</div>
<Select
value={decisions[conflict.skillName] || 'skip'}
onValueChange={(v) => setDecisions((prev) => ({ ...prev, [conflict.skillName]: v as ConflictDecision }))}
>
<SelectTrigger className="!h-9 w-36 justify-between">
<span className="capitalize">{decisions[conflict.skillName] || 'skip'}</span>
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="skip" className="pr-2 [&>span:first-child]:hidden">
Skip
</SelectItem>
<SelectItem value="overwrite" className="pr-2 [&>span:first-child]:hidden">
Overwrite
</SelectItem>
</SelectContent>
</Select>
</div>
))}
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<ButtonLarge
onClick={() => onConfirm(decisions)}
disabled={!canConfirm}
>
Continue
</ButtonLarge>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,418 @@
import React from 'react';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { ButtonLarge } from '@/components/ui/button-large';
import { Input } from '@/components/ui/input';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from '@/components/ui/select';
import { RiFolderLine, RiGitRepositoryLine, RiUser3Line } from '@remixicon/react';
import { isVSCodeRuntime } from '@/lib/desktop';
import type { SkillsCatalogItem } from '@/lib/api/types';
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { InstallConflictsDialog, type ConflictDecision, type SkillConflict } from './InstallConflictsDialog';
interface InstallFromRepoDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
type IdentityOption = { id: string; name: string };
export const InstallFromRepoDialog: React.FC<InstallFromRepoDialogProps> = ({ open, onOpenChange }) => {
const { scanRepo, installSkills, isScanning, isInstalling } = useSkillsCatalogStore();
const installedSkills = useSkillsStore((s) => s.skills);
const [source, setSource] = React.useState('');
const [subpath, setSubpath] = React.useState('');
const [scope, setScope] = React.useState<'user' | 'project'>('user');
const [items, setItems] = React.useState<SkillsCatalogItem[]>([]);
const [selected, setSelected] = React.useState<Record<string, boolean>>({});
const [search, setSearch] = React.useState('');
const [identities, setIdentities] = React.useState<IdentityOption[]>([]);
const [gitIdentityId, setGitIdentityId] = React.useState<string | null>(null);
const [conflictsOpen, setConflictsOpen] = React.useState(false);
const [conflicts, setConflicts] = React.useState<SkillConflict[]>([]);
const [baseInstallRequest, setBaseInstallRequest] = React.useState<{
source: string;
subpath?: string;
scope: 'user' | 'project';
selections: Array<{ skillDir: string }>;
gitIdentityId?: string;
} | null>(null);
React.useEffect(() => {
if (!open) return;
setSource('');
setSubpath('');
setScope('user');
setItems([]);
setSelected({});
setSearch('');
setIdentities([]);
setGitIdentityId(null);
setConflictsOpen(false);
setConflicts([]);
setBaseInstallRequest(null);
}, [open]);
const installedByName = React.useMemo(() => {
const map = new Map<string, { scope: 'user' | 'project' }>();
for (const s of installedSkills) {
map.set(s.name, { scope: s.scope });
}
return map;
}, [installedSkills]);
const filteredItems = React.useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return items;
return items.filter((item) => {
const name = item.skillName.toLowerCase();
const desc = (item.description || '').toLowerCase();
const fm = (item.frontmatterName || '').toLowerCase();
return name.includes(q) || desc.includes(q) || fm.includes(q);
});
}, [items, search]);
const selectedDirs = React.useMemo(() => Object.keys(selected).filter((k) => selected[k]), [selected]);
const toggleAll = (value: boolean) => {
const next: Record<string, boolean> = {};
for (const item of items) {
if (!item.installable) continue;
next[item.skillDir] = value;
}
setSelected(next);
};
const handleScan = async () => {
const trimmed = source.trim();
if (!trimmed) {
toast.error('Repository source is required');
return;
}
const result = await scanRepo({
source: trimmed,
subpath: subpath.trim() || undefined,
gitIdentityId: gitIdentityId || undefined,
});
if (!result.ok) {
if (result.error?.kind === 'authRequired') {
if (isVSCodeRuntime()) {
toast.error('Private repositories are not supported in VS Code yet');
return;
}
const ids = (result.error.identities || []) as IdentityOption[];
setIdentities(ids);
if (!gitIdentityId && ids.length > 0) {
setGitIdentityId(ids[0].id);
}
toast.error('Authentication required. Select a Git identity and try scanning again.');
return;
}
toast.error(result.error?.message || 'Failed to scan repository');
return;
}
const nextItems = result.items || [];
setItems(nextItems);
// Auto-select all installable items when scanning returns a small set.
const nextSelected: Record<string, boolean> = {};
for (const item of nextItems) {
if (item.installable) {
nextSelected[item.skillDir] = true;
}
}
setSelected(nextSelected);
setIdentities([]);
toast.success(`Found ${nextItems.length} skill(s)`);
};
const doInstall = async (opts: { conflictDecisions?: Record<string, ConflictDecision> }) => {
if (selectedDirs.length === 0) {
toast.error('Select at least one skill to install');
return;
}
const request = {
source: source.trim(),
subpath: subpath.trim() || undefined,
scope,
selections: selectedDirs.map((dir) => ({ skillDir: dir })),
gitIdentityId: gitIdentityId || undefined,
};
const result = await installSkills({
...request,
conflictPolicy: 'prompt',
conflictDecisions: opts.conflictDecisions,
});
if (result.ok) {
const installedCount = result.installed?.length || 0;
toast.success(installedCount > 0 ? `Installed ${installedCount} skill(s)` : 'Installation completed');
onOpenChange(false);
return;
}
if (result.error?.kind === 'conflicts') {
setBaseInstallRequest(request);
setConflicts(result.error.conflicts);
setConflictsOpen(true);
return;
}
if (result.error?.kind === 'authRequired') {
if (isVSCodeRuntime()) {
toast.error('Private repositories are not supported in VS Code yet');
return;
}
const ids = (result.error.identities || []) as IdentityOption[];
setIdentities(ids);
if (!gitIdentityId && ids.length > 0) {
setGitIdentityId(ids[0].id);
}
toast.error('Authentication required. Select a Git identity and try installing again.');
return;
}
toast.error(result.error?.message || 'Failed to install skills');
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-3xl max-h-[85vh] flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle>Install from Git repository</DialogTitle>
<DialogDescription>
Scan a repository for folders containing <code className="font-mono">SKILL.md</code>, then install selected skills.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 flex-shrink-0">
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">Repository</label>
<div className="flex items-center gap-2">
<Input
value={source}
onChange={(e) => setSource(e.target.value)}
placeholder="owner/repo or git@github.com:owner/repo.git"
className="text-foreground placeholder:text-muted-foreground"
/>
<Button
type="button"
variant="outline"
onClick={() => void handleScan()}
disabled={isScanning || !source.trim()}
className="gap-2"
>
<RiGitRepositoryLine className="h-4 w-4" />
{isScanning ? 'Scanning…' : 'Scan'}
</Button>
</div>
<p className="typography-meta text-muted-foreground">
For GitHub shorthand, you can add a subpath like <code className="font-mono">owner/repo/skills</code>.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">Optional subpath</label>
<Input
value={subpath}
onChange={(e) => setSubpath(e.target.value)}
placeholder="e.g. skills"
className="text-foreground placeholder:text-muted-foreground"
/>
</div>
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">Target scope</label>
<Select value={scope} onValueChange={(v) => setScope(v as 'user' | 'project')}>
<SelectTrigger className="!h-9 w-full gap-1.5">
{scope === 'user' ? <RiUser3Line className="h-4 w-4" /> : <RiFolderLine className="h-4 w-4" />}
<span className="capitalize">{scope}</span>
</SelectTrigger>
<SelectContent align="start">
<SelectItem value="user" className="pr-2 [&>span:first-child]:hidden">
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
<RiUser3Line className="h-4 w-4" />
<span>User</span>
</div>
<span className="typography-micro text-muted-foreground ml-6">Available in all projects</span>
</div>
</SelectItem>
<SelectItem value="project" className="pr-2 [&>span:first-child]:hidden">
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
<RiFolderLine className="h-4 w-4" />
<span>Project</span>
</div>
<span className="typography-micro text-muted-foreground ml-6">Only in current project</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{identities.length > 0 && !isVSCodeRuntime() ? (
<div className="rounded-lg border bg-muted/20 px-3 py-2">
<div className="typography-ui-label font-medium text-foreground">Authentication required</div>
<div className="typography-meta text-muted-foreground mt-1">
Select a Git identity (SSH key) that can access this repository.
</div>
<div className="mt-2">
<Select value={gitIdentityId || ''} onValueChange={(v) => setGitIdentityId(v)}>
<SelectTrigger className="!h-9 w-full justify-between">
<span>{identities.find((i) => i.id === gitIdentityId)?.name || 'Choose identity'}</span>
</SelectTrigger>
<SelectContent align="start">
{identities.map((id) => (
<SelectItem key={id.id} value={id.id} className="pr-2 [&>span:first-child]:hidden">
{id.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="typography-micro text-muted-foreground mt-2">
Configure identities in Settings Git Identities.
</div>
</div>
) : null}
</div>
<div className="flex-1 min-h-0">
{items.length === 0 ? (
<div className="flex h-full items-center justify-center text-center text-muted-foreground">
<div>
<p className="typography-body">No scan results yet</p>
<p className="typography-meta mt-1 opacity-75">Scan a repository to discover skills</p>
</div>
</div>
) : (
<div className="h-full flex flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search skills…"
className="max-w-sm"
/>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => toggleAll(true)}>Select all</Button>
<Button variant="outline" size="sm" onClick={() => toggleAll(false)}>Select none</Button>
</div>
</div>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-2">
{filteredItems.map((item) => {
const installed = installedByName.get(item.skillName);
const checked = Boolean(selected[item.skillDir]);
const disabled = !item.installable;
return (
<label
key={item.skillDir}
className={
'flex items-start gap-3 rounded-lg border bg-muted/10 px-3 py-2 cursor-pointer transition-colors ' +
(disabled ? 'opacity-60 cursor-not-allowed' : 'hover:bg-muted/20')
}
>
<input
type="checkbox"
className="mt-1 h-4 w-4"
checked={checked}
disabled={disabled}
onChange={(e) => setSelected((prev) => ({ ...prev, [item.skillDir]: e.target.checked }))}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<div className="typography-ui-label truncate">{item.skillName}</div>
{installed ? (
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
installed ({installed.scope})
</span>
) : null}
</div>
{item.description ? (
<div className="typography-meta text-muted-foreground mt-0.5 line-clamp-2">{item.description}</div>
) : (
<div className="typography-micro text-muted-foreground mt-0.5">No description provided</div>
)}
{item.warnings?.length ? (
<div className="typography-micro text-muted-foreground mt-1">
{item.warnings.join(' · ')}
</div>
) : null}
</div>
</label>
);
})}
</ScrollableOverlay>
<div className="typography-meta text-muted-foreground">
Selected: {selectedDirs.length} / {items.filter((i) => i.installable).length}
</div>
</div>
)}
</div>
<DialogFooter className="flex-shrink-0">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<ButtonLarge
disabled={isInstalling || selectedDirs.length === 0 || !source.trim()}
onClick={() => void doInstall({})}
>
{isInstalling ? 'Installing…' : 'Install selected'}
</ButtonLarge>
</DialogFooter>
</DialogContent>
</Dialog>
<InstallConflictsDialog
open={conflictsOpen}
onOpenChange={setConflictsOpen}
conflicts={conflicts}
onConfirm={(decisions) => {
if (!baseInstallRequest) {
setConflictsOpen(false);
return;
}
void doInstall({ conflictDecisions: decisions });
setConflictsOpen(false);
}}
/>
</>
);
};
@@ -0,0 +1,185 @@
import React from 'react';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from '@/components/ui/select';
import { RiFolderLine, RiUser3Line } from '@remixicon/react';
import type { SkillsCatalogItem } from '@/lib/api/types';
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
import { InstallConflictsDialog, type ConflictDecision, type SkillConflict } from './InstallConflictsDialog';
interface InstallSkillDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
item: SkillsCatalogItem | null;
}
export const InstallSkillDialog: React.FC<InstallSkillDialogProps> = ({ open, onOpenChange, item }) => {
const { installSkills, isInstalling } = useSkillsCatalogStore();
const [scope, setScope] = React.useState<'user' | 'project'>('user');
const [conflictsOpen, setConflictsOpen] = React.useState(false);
const [conflicts, setConflicts] = React.useState<SkillConflict[]>([]);
const [baseRequest, setBaseRequest] = React.useState<{
source: string;
subpath?: string;
scope: 'user' | 'project';
skillDir: string;
} | null>(null);
React.useEffect(() => {
if (!open) return;
setScope('user');
setConflictsOpen(false);
setConflicts([]);
setBaseRequest(null);
}, [open]);
const doInstall = async (request: {
source: string;
subpath?: string;
scope: 'user' | 'project';
skillDir: string;
conflictDecisions?: Record<string, ConflictDecision>;
}) => {
const result = await installSkills({
source: request.source,
subpath: request.subpath,
gitIdentityId: item?.gitIdentityId,
scope: request.scope,
selections: [{ skillDir: request.skillDir }],
conflictPolicy: 'prompt',
conflictDecisions: request.conflictDecisions,
});
if (result.ok) {
toast.success('Skill installed successfully');
onOpenChange(false);
return;
}
if (result.error?.kind === 'conflicts') {
setBaseRequest({ source: request.source, subpath: request.subpath, scope: request.scope, skillDir: request.skillDir });
setConflicts(result.error.conflicts);
setConflictsOpen(true);
return;
}
if (result.error?.kind === 'authRequired') {
toast.error(result.error.message || 'Authentication required');
return;
}
toast.error(result.error?.message || 'Failed to install skill');
};
if (!item) {
return null;
}
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Install skill</DialogTitle>
<DialogDescription>
Install <span className="font-semibold text-foreground">{item.skillName}</span> into user or project scope.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
{item.warnings?.length ? (
<div className="rounded-lg border bg-muted/30 px-3 py-2">
<div className="typography-micro text-muted-foreground">Warnings</div>
<ul className="mt-1 space-y-1">
{item.warnings.map((w) => (
<li key={w} className="typography-meta text-muted-foreground">{w}</li>
))}
</ul>
</div>
) : null}
</div>
<DialogFooter className="flex flex-col gap-3 sm:flex-row sm:justify-between sm:items-center">
<div className="flex items-center gap-2">
<Select value={scope} onValueChange={(v) => setScope(v as 'user' | 'project')}>
<SelectTrigger className="!h-9 w-full sm:w-36 justify-between">
<span className="flex flex-1 items-center gap-2 justify-start">
{scope === 'user' ? <RiUser3Line className="h-4 w-4" /> : <RiFolderLine className="h-4 w-4" />}
<span className="capitalize">{scope}</span>
</span>
</SelectTrigger>
<SelectContent align="start">
<SelectItem value="user" className="pr-2 [&>span:first-child]:hidden">
<div className="flex items-center gap-2">
<RiUser3Line className="h-4 w-4" />
<span>User</span>
</div>
</SelectItem>
<SelectItem value="project" className="pr-2 [&>span:first-child]:hidden">
<div className="flex items-center gap-2">
<RiFolderLine className="h-4 w-4" />
<span>Project</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<Button className="w-full sm:w-auto" variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
className="w-full sm:w-auto"
variant="default"
disabled={isInstalling || !item.installable}
onClick={() =>
void doInstall({
source: item.repoSource,
subpath: item.repoSubpath,
scope,
skillDir: item.skillDir,
})
}
>
{isInstalling ? 'Installing…' : 'Install'}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
<InstallConflictsDialog
open={conflictsOpen}
onOpenChange={setConflictsOpen}
conflicts={conflicts}
onConfirm={(decisions) => {
if (!baseRequest) return;
void doInstall({
source: baseRequest.source,
subpath: baseRequest.subpath,
scope: baseRequest.scope,
skillDir: baseRequest.skillDir,
conflictDecisions: decisions,
});
setConflictsOpen(false);
}}
/>
</>
);
};
@@ -0,0 +1,285 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { AnimatedTabs } from '@/components/ui/animated-tabs';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from '@/components/ui/select';
import { RiAddLine, RiDeleteBinLine, RiRefreshLine } from '@remixicon/react';
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
import type { SkillsCatalogItem } from '@/lib/api/types';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { getDesktopSettings, isDesktopRuntime } from '@/lib/desktop';
import { updateDesktopSettings } from '@/lib/persistence';
import type { DesktopSettings, SkillCatalogConfig } from '@/lib/desktop';
import { AddCatalogDialog } from './AddCatalogDialog';
import { InstallSkillDialog } from './InstallSkillDialog';
type SkillsMode = 'manual' | 'external';
interface SkillsCatalogPageProps {
mode: SkillsMode;
onModeChange: (mode: SkillsMode) => void;
}
const loadSettings = async (): Promise<DesktopSettings | null> => {
try {
if (isDesktopRuntime()) {
return await getDesktopSettings();
}
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
if (runtimeSettings) {
const result = await runtimeSettings.load();
return (result?.settings || {}) as DesktopSettings;
}
const response = await fetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
return null;
}
return (await response.json().catch(() => null)) as DesktopSettings | null;
} catch {
return null;
}
};
export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onModeChange }) => {
const {
sources,
itemsBySource,
selectedSourceId,
setSelectedSource,
loadCatalog,
isLoadingCatalog,
lastCatalogError,
} = useSkillsCatalogStore();
const [search, setSearch] = React.useState('');
const [addCatalogOpen, setAddCatalogOpen] = React.useState(false);
const [installDialogOpen, setInstallDialogOpen] = React.useState(false);
const [installItem, setInstallItem] = React.useState<SkillsCatalogItem | null>(null);
const [isRemovingCatalog, setIsRemovingCatalog] = React.useState(false);
React.useEffect(() => {
void loadCatalog();
}, [loadCatalog]);
const items = React.useMemo(() => {
if (!selectedSourceId) return [];
return itemsBySource[selectedSourceId] || [];
}, [itemsBySource, selectedSourceId]);
const filtered = React.useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return items;
return items.filter((item) => {
const name = item.skillName.toLowerCase();
const desc = (item.description || '').toLowerCase();
const fm = (item.frontmatterName || '').toLowerCase();
return name.includes(q) || desc.includes(q) || fm.includes(q);
});
}, [items, search]);
const selectedSource = React.useMemo(() => sources.find((s) => s.id === selectedSourceId) || null, [sources, selectedSourceId]);
const isCustomSource = Boolean(selectedSourceId && selectedSourceId.startsWith('custom:'));
const removeSelectedCatalog = async () => {
if (!selectedSourceId || !isCustomSource) {
return;
}
if (!window.confirm('Remove this catalog?')) {
return;
}
setIsRemovingCatalog(true);
try {
const settings = await loadSettings();
const catalogs = (Array.isArray(settings?.skillCatalogs) ? settings?.skillCatalogs : []) as SkillCatalogConfig[];
const updated = catalogs.filter((c) => c.id !== selectedSourceId);
await updateDesktopSettings({ skillCatalogs: updated });
await loadCatalog({ refresh: true });
} finally {
setIsRemovingCatalog(false);
}
};
return (
<ScrollableOverlay outerClassName="h-full" className="mx-auto max-w-3xl space-y-6 p-6">
<div className="space-y-3">
<AnimatedTabs
tabs={[
{ value: 'manual', label: 'Manual' },
{ value: 'external', label: 'External' },
]}
value={mode}
onValueChange={onModeChange}
animate={false}
/>
<div className="space-y-1">
<h1 className="typography-ui-header font-semibold text-lg">Skills Catalog</h1>
<p className="typography-meta text-muted-foreground">
Browse curated repositories and install skills into your OpenCode configuration.
</p>
</div>
</div>
<div className="space-y-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="flex-1 space-y-2">
<label className="typography-ui-label font-medium text-foreground">Source</label>
<Select
value={selectedSourceId || ''}
onValueChange={(v) => setSelectedSource(v)}
>
<SelectTrigger className="!h-9 w-full justify-between">
<span>{selectedSource?.label || 'Select source'}</span>
</SelectTrigger>
<SelectContent align="start">
{sources.map((src) => (
<SelectItem key={src.id} value={src.id} className="pr-2 [&>span:first-child]:hidden">
{src.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={() => void loadCatalog({ refresh: true })}
disabled={isLoadingCatalog}
className="gap-2"
>
<RiRefreshLine className="h-4 w-4" />
Refresh
</Button>
{isCustomSource ? (
<Button
type="button"
variant="outline"
onClick={() => void removeSelectedCatalog()}
disabled={isRemovingCatalog}
className="gap-2"
>
<RiDeleteBinLine className="h-4 w-4" />
Remove
</Button>
) : null}
<Button
type="button"
variant="default"
onClick={() => setAddCatalogOpen(true)}
className="gap-2"
>
<RiAddLine className="h-4 w-4" />
Add catalog
</Button>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search skills…"
className="max-w-md"
/>
<div className="typography-meta text-muted-foreground">
{isLoadingCatalog ? 'Loading…' : `${filtered.length} skill(s)`}
</div>
</div>
{lastCatalogError ? (
<div className="rounded-lg border bg-muted/20 px-3 py-2">
<div className="typography-ui-label font-medium text-foreground">Catalog error</div>
<div className="typography-meta text-muted-foreground mt-1">{lastCatalogError.message}</div>
</div>
) : null}
</div>
<div className="space-y-2">
{filtered.length === 0 ? (
<div className="py-10 text-center text-muted-foreground">
<p className="typography-body">No skills found</p>
<p className="typography-meta mt-1 opacity-75">Try a different search or refresh the catalog</p>
</div>
) : (
filtered.map((item) => {
const installed = item.installed?.isInstalled;
const installedScope = item.installed?.scope;
return (
<div
key={`${item.sourceId}:${item.skillDir}`}
className="rounded-lg border bg-muted/10 px-3 py-2"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="typography-ui-label truncate">{item.skillName}</div>
{installed ? (
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
installed ({installedScope || 'unknown'})
</span>
) : null}
{!item.installable ? (
<span className="typography-micro text-muted-foreground bg-muted px-1 rounded flex-shrink-0 leading-none pb-px border border-border/50">
not installable
</span>
) : null}
</div>
{item.description ? (
<div className="typography-meta text-muted-foreground mt-0.5 line-clamp-2">{item.description}</div>
) : (
<div className="typography-micro text-muted-foreground mt-0.5">No description provided</div>
)}
{item.warnings?.length ? (
<div className="typography-micro text-muted-foreground mt-1">{item.warnings.join(' · ')}</div>
) : null}
</div>
<Button
type="button"
variant="outline"
size="sm"
className="gap-2"
disabled={!item.installable}
onClick={() => {
setInstallItem(item);
setInstallDialogOpen(true);
}}
>
Install
</Button>
</div>
</div>
);
})
)}
</div>
<AddCatalogDialog open={addCatalogOpen} onOpenChange={setAddCatalogOpen} />
<InstallSkillDialog open={installDialogOpen} onOpenChange={setInstallDialogOpen} item={installItem} />
</ScrollableOverlay>
);
};
@@ -13,6 +13,7 @@ interface AnimatedTabsProps<T extends string> {
onValueChange: (value: T) => void;
className?: string;
isInteractive?: boolean;
animate?: boolean;
}
export function AnimatedTabs<T extends string>({
@@ -21,6 +22,7 @@ export function AnimatedTabs<T extends string>({
onValueChange,
className,
isInteractive = true,
animate = true,
}: AnimatedTabsProps<T>) {
const containerRef = React.useRef<HTMLDivElement>(null);
const activeTabRef = React.useRef<HTMLButtonElement>(null);
@@ -41,7 +43,7 @@ export function AnimatedTabs<T extends string>({
container.style.clipPath = `inset(0 ${Number(100 - rightPercent).toFixed(2)}% 0 ${Number(leftPercent).toFixed(2)}% round 8px)`;
}, []);
React.useEffect(() => {
React.useLayoutEffect(() => {
updateClipPath();
}, [updateClipPath, value, tabs.length]);
@@ -60,7 +62,10 @@ export function AnimatedTabs<T extends string>({
<div
ref={containerRef}
aria-hidden
className="pointer-events-none absolute inset-0 z-10 overflow-hidden rounded-lg [clip-path:inset(0_75%_0_0_round_8px)] [transition:clip-path_200ms_ease]"
className={cn(
'pointer-events-none absolute inset-0 z-10 overflow-hidden rounded-lg [clip-path:inset(0_75%_0_0_round_8px)]',
animate ? '[transition:clip-path_200ms_ease]' : null
)}
>
<div className="flex h-9 items-center gap-1 rounded-lg bg-accent px-1.5 text-accent-foreground">
{tabs.map((tab) => {
+84
View File
@@ -412,3 +412,87 @@ export interface RuntimeAPIs {
}
export type RuntimeAPISelector<TValue> = (apis: RuntimeAPIs) => TValue;
// ============== Skills Catalog Types ==============
export type SkillsCatalogSourceId = string;
export interface SkillsCatalogSource {
id: SkillsCatalogSourceId;
label: string;
description?: string;
source: string;
defaultSubpath?: string;
}
export interface SkillsCatalogItemInstalledBadge {
isInstalled: boolean;
scope?: 'user' | 'project';
}
export interface SkillsCatalogItem {
sourceId: SkillsCatalogSourceId;
repoSource: string;
repoSubpath?: string;
gitIdentityId?: string;
skillDir: string;
skillName: string;
frontmatterName?: string;
description?: string;
installable: boolean;
warnings?: string[];
installed?: SkillsCatalogItemInstalledBadge;
}
export interface SkillsCatalogResponse {
ok: boolean;
sources?: SkillsCatalogSource[];
itemsBySource?: Record<SkillsCatalogSourceId, SkillsCatalogItem[]>;
error?: { kind: string; message: string };
}
export interface SkillsRepoScanRequest {
source: string;
subpath?: string;
gitIdentityId?: string;
}
export type SkillsRepoScanError =
| { kind: 'authRequired'; message: string; sshOnly: true; identities?: Array<{ id: string; name: string }> }
| { kind: 'invalidSource'; message: string }
| { kind: 'gitUnavailable'; message: string }
| { kind: 'networkError'; message: string }
| { kind: 'unknown'; message: string };
export interface SkillsRepoScanResponse {
ok: boolean;
items?: SkillsCatalogItem[];
error?: SkillsRepoScanError;
}
export interface SkillsInstallSelection {
skillDir: string;
}
export interface SkillsInstallRequest {
source: string;
subpath?: string;
gitIdentityId?: string;
scope: 'user' | 'project';
selections: SkillsInstallSelection[];
conflictPolicy?: 'prompt' | 'skipAll' | 'overwriteAll';
conflictDecisions?: Record<string, 'skip' | 'overwrite'>;
}
export type SkillsInstallError = SkillsRepoScanError | {
kind: 'conflicts';
message: string;
conflicts: Array<{ skillName: string; scope: 'user' | 'project' }>;
};
export interface SkillsInstallResponse {
ok: boolean;
installed?: Array<{ skillName: string; scope: 'user' | 'project' }>;
skipped?: Array<{ skillName: string; reason: string }>;
error?: SkillsInstallError;
}
+11
View File
@@ -27,6 +27,14 @@ export type DesktopServerInfo = {
cliAvailable: boolean;
};
export type SkillCatalogConfig = {
id: string;
label: string;
source: string;
subpath?: string;
gitIdentityId?: string;
};
export type DesktopSettings = {
themeId?: string;
useSystemTheme?: boolean;
@@ -44,6 +52,9 @@ export type DesktopSettings = {
defaultModel?: string; // format: "provider/model"
defaultAgent?: string;
queueModeEnabled?: boolean;
// User-added skills catalogs (persisted to ~/.config/openchamber/settings.json)
skillCatalogs?: SkillCatalogConfig[];
};
export type DesktopSettingsApi = {
+39
View File
@@ -44,6 +44,40 @@ type PersistApi = {
onFinishHydration?: (callback: () => void) => (() => void) | void;
};
const sanitizeSkillCatalogs = (value: unknown): DesktopSettings['skillCatalogs'] | undefined => {
if (!Array.isArray(value)) {
return undefined;
}
const result: NonNullable<DesktopSettings['skillCatalogs']> = [];
const seen = new Set<string>();
for (const entry of value) {
if (!entry || typeof entry !== 'object') continue;
const candidate = entry as Record<string, unknown>;
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
const label = typeof candidate.label === 'string' ? candidate.label.trim() : '';
const source = typeof candidate.source === 'string' ? candidate.source.trim() : '';
const subpath = typeof candidate.subpath === 'string' ? candidate.subpath.trim() : '';
const gitIdentityId = typeof candidate.gitIdentityId === 'string' ? candidate.gitIdentityId.trim() : '';
if (!id || !label || !source) continue;
if (seen.has(id)) continue;
seen.add(id);
result.push({
id,
label,
source,
...(subpath ? { subpath } : {}),
...(gitIdentityId ? { gitIdentityId } : {}),
});
}
return result;
};
const getPersistApi = (): PersistApi | undefined => {
const candidate = (useUIStore as unknown as { persist?: PersistApi }).persist;
if (candidate && typeof candidate === 'object') {
@@ -140,6 +174,11 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
result.queueModeEnabled = candidate.queueModeEnabled;
}
const skillCatalogs = sanitizeSkillCatalogs(candidate.skillCatalogs);
if (skillCatalogs) {
result.skillCatalogs = skillCatalogs;
}
return result;
};
@@ -0,0 +1,206 @@
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import type {
SkillsCatalogResponse,
SkillsCatalogSource,
SkillsCatalogItem,
SkillsRepoScanRequest,
SkillsRepoScanResponse,
SkillsInstallRequest,
SkillsInstallResponse,
SkillsInstallError,
} from '@/lib/api/types';
import { useSkillsStore } from '@/stores/useSkillsStore';
const getCurrentDirectory = (): string | null => {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const store = (window as any).__zustand_directory_store__;
if (store) {
return store.getState().currentDirectory;
}
} catch {
// ignore
}
return null;
};
export interface SkillsCatalogState {
sources: SkillsCatalogSource[];
itemsBySource: Record<string, SkillsCatalogItem[]>;
selectedSourceId: string | null;
isLoadingCatalog: boolean;
isScanning: boolean;
isInstalling: boolean;
lastCatalogError: SkillsCatalogResponse['error'] | null;
lastScanError: SkillsRepoScanResponse['error'] | null;
lastInstallError: SkillsInstallError | null;
scanResults: SkillsCatalogItem[] | null;
setSelectedSource: (id: string | null) => void;
loadCatalog: (options?: { refresh?: boolean }) => Promise<boolean>;
scanRepo: (request: SkillsRepoScanRequest) => Promise<SkillsRepoScanResponse>;
installSkills: (request: SkillsInstallRequest) => Promise<SkillsInstallResponse>;
}
export const useSkillsCatalogStore = create<SkillsCatalogState>()(
devtools(
(set, get) => ({
sources: [],
itemsBySource: {},
selectedSourceId: null,
isLoadingCatalog: false,
isScanning: false,
isInstalling: false,
lastCatalogError: null,
lastScanError: null,
lastInstallError: null,
scanResults: null,
setSelectedSource: (id) => set({ selectedSourceId: id }),
loadCatalog: async (options) => {
set({ isLoadingCatalog: true, lastCatalogError: null });
const previous = {
sources: get().sources,
itemsBySource: get().itemsBySource,
};
let lastError: SkillsCatalogResponse['error'] | null = null;
try {
for (let attempt = 0; attempt < 3; attempt++) {
try {
const currentDirectory = getCurrentDirectory();
const refresh = options?.refresh ? '&refresh=true' : '';
const queryParams = currentDirectory
? `?directory=${encodeURIComponent(currentDirectory)}${refresh}`
: refresh
? `?refresh=true`
: '';
const response = await fetch(`/api/config/skills/catalog${queryParams}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
const payload = (await response.json().catch(() => null)) as SkillsCatalogResponse | null;
if (!response.ok || !payload?.ok) {
lastError = payload?.error || { kind: 'unknown', message: `Failed to load catalog (${response.status})` };
const waitMs = 200 * (attempt + 1);
await new Promise((resolve) => setTimeout(resolve, waitMs));
continue;
}
const sources = payload.sources || [];
const itemsBySource = payload.itemsBySource || {};
const currentSelected = get().selectedSourceId;
const selectedSourceId =
(currentSelected && sources.some((s) => s.id === currentSelected))
? currentSelected
: (sources[0]?.id ?? null);
set({ sources, itemsBySource, selectedSourceId });
return true;
} catch (error) {
lastError = { kind: 'unknown', message: error instanceof Error ? error.message : String(error) };
const waitMs = 200 * (attempt + 1);
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
}
set({
sources: previous.sources,
itemsBySource: previous.itemsBySource,
lastCatalogError: lastError || { kind: 'unknown', message: 'Failed to load catalog' },
});
return false;
} finally {
set({ isLoadingCatalog: false });
}
},
scanRepo: async (request) => {
set({ isScanning: true, lastScanError: null, scanResults: null });
try {
const currentDirectory = getCurrentDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const response = await fetch(`/api/config/skills/scan${queryParams}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(request),
});
const payload = (await response.json().catch(() => null)) as SkillsRepoScanResponse | null;
if (!response.ok || !payload) {
const error = payload?.error || { kind: 'unknown', message: 'Failed to scan repository' };
set({ lastScanError: error });
return { ok: false, error };
}
if (!payload.ok) {
set({ lastScanError: payload.error || { kind: 'unknown', message: 'Failed to scan repository' } });
return payload;
}
set({ scanResults: payload.items || [] });
return payload;
} finally {
set({ isScanning: false });
}
},
installSkills: async (request) => {
set({ isInstalling: true, lastInstallError: null });
try {
const currentDirectory = getCurrentDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const response = await fetch(`/api/config/skills/install${queryParams}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(request),
});
const payload = (await response.json().catch(() => null)) as SkillsInstallResponse | null;
if (!payload) {
const error = { kind: 'unknown', message: 'Failed to install skills' } as SkillsInstallError;
set({ lastInstallError: error });
return { ok: false, error };
}
if (!response.ok || !payload.ok) {
const error = payload.error || ({ kind: 'unknown', message: 'Failed to install skills' } as SkillsInstallError);
set({ lastInstallError: error });
return { ok: false, error };
}
// Refresh installed skills list.
void useSkillsStore.getState().loadSkills();
return payload;
} catch (error) {
const err = { kind: 'unknown', message: error instanceof Error ? error.message : String(error) } as SkillsInstallError;
set({ lastInstallError: err });
return { ok: false, error: err };
} finally {
set({ isInstalling: false });
}
},
}),
{ name: 'skills-catalog-store' }
)
);