feat(skills): add provider skill management

Users need one settings surface to discover and install skills without manually navigating provider-specific directories.

Add provider-backed global skill installation for Claude, Codex, Gemini, and Cursor, while keeping OpenCode read-only because it reuses other providers' skill locations.

Add a responsive Skills settings tab with scoped discovery, search, refresh controls, markdown and folder uploads, upload feedback, and overflow-safe layouts.

Validate bundled skill files and paths before writing them, preserve scripts and assets, and cover provider discovery and installation behavior with tests.
This commit is contained in:
Haileyesus
2026-06-21 01:17:23 +03:00
parent 4712431be8
commit be9fdd165e
22 changed files with 1578 additions and 14 deletions

View File

@@ -5,7 +5,7 @@ import type { ProviderAuthStatus } from '../../provider-auth/types';
export type SettingsMainTab = 'agents' | 'appearance' | 'git' | 'api' | 'tasks' | 'browser' | 'notifications' | 'plugins' | 'about';
export type AgentProvider = LLMProvider;
export type AgentCategory = 'account' | 'permissions' | 'mcp';
export type AgentCategory = 'account' | 'permissions' | 'mcp' | 'skills';
export type ProjectSortOrder = 'name' | 'date';
export type SaveStatus = 'success' | 'error' | null;
export type CodexPermissionMode = 'default' | 'acceptEdits' | 'bypassPermissions';

View File

@@ -1,5 +1,6 @@
import { X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import ProviderLoginModal from '../../provider-auth/view/ProviderLoginModal';
import { Button } from '../../../shared/view/ui';
import SettingsSidebar from '../view/SettingsSidebar';
@@ -101,12 +102,12 @@ function Settings({ isOpen, onClose, projects = [], initialTab = 'agents' }: Set
</div>
{/* Body: sidebar + content */}
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
<div className="flex min-h-0 min-w-0 flex-1 flex-col md:flex-row">
<SettingsSidebar activeTab={activeTab} onChange={setActiveTab} />
{/* Content */}
<main className="flex-1 overflow-y-auto">
<div key={activeTab} className="settings-content-enter space-y-6 p-4 pb-safe-area-inset-bottom md:space-y-8 md:p-6">
<main className="min-w-0 flex-1 overflow-y-auto overflow-x-hidden">
<div key={activeTab} className="settings-content-enter min-w-0 space-y-6 overflow-x-hidden p-4 pb-safe-area-inset-bottom md:space-y-8 md:p-6">
{activeTab === 'appearance' && (
<AppearanceSettingsTab
projectSortOrder={projectSortOrder}

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import type { AgentCategory, AgentProvider } from '../../../types/types';
@@ -22,6 +22,11 @@ export default function AgentsSettingsTab({
}: AgentsSettingsTabProps) {
const [selectedAgent, setSelectedAgent] = useState<AgentProvider>('claude');
const [selectedCategory, setSelectedCategory] = useState<AgentCategory>('account');
const visibleCategories = useMemo<AgentCategory[]>(() => (
selectedAgent === 'opencode'
? ['account', 'permissions', 'mcp']
: ['account', 'permissions', 'mcp', 'skills']
), [selectedAgent]);
const visibleAgents = useMemo<AgentProvider[]>(() => {
return ['claude', 'cursor', 'codex', 'gemini', 'opencode'];
@@ -57,8 +62,14 @@ export default function AgentsSettingsTab({
providerAuthStatus.opencode,
]);
useEffect(() => {
if (!visibleCategories.includes(selectedCategory)) {
setSelectedCategory(visibleCategories[0] ?? 'account');
}
}, [selectedCategory, visibleCategories]);
return (
<div className="-mx-4 -mb-4 -mt-2 flex min-h-[300px] flex-col overflow-hidden md:-mx-6 md:-mb-6 md:-mt-2 md:min-h-[500px]">
<div className="-mx-4 -mb-4 -mt-2 flex min-h-[300px] min-w-0 flex-col overflow-hidden md:-mx-6 md:-mb-6 md:-mt-2 md:min-h-[500px]">
<AgentSelectorSection
agents={visibleAgents}
selectedAgent={selectedAgent}
@@ -66,8 +77,10 @@ export default function AgentsSettingsTab({
agentContextById={agentContextById}
/>
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<AgentCategoryTabsSection
categories={visibleCategories}
selectedAgent={selectedAgent}
selectedCategory={selectedCategory}
onSelectCategory={setSelectedCategory}
/>

View File

@@ -1,6 +1,8 @@
import type { AgentCategoryContentSectionProps } from '../types';
import type { McpProject } from '../../../../../mcp/types';
import { McpServers } from '../../../../../mcp';
import type { SkillsProject } from '../../../../../skills/types';
import { ProviderSkills } from '../../../../../skills';
import AccountContent from './content/AccountContent';
import PermissionsContent from './content/PermissionsContent';
@@ -18,7 +20,7 @@ export default function AgentCategoryContentSection({
projects,
}: AgentCategoryContentSectionProps) {
return (
<div className="flex-1 overflow-y-auto p-3 md:p-4">
<div className="min-w-0 flex-1 overflow-y-auto overflow-x-hidden p-3 md:p-4">
{selectedCategory === 'account' && (
<AccountContent
agent={selectedAgent}
@@ -84,6 +86,18 @@ export default function AgentCategoryContentSection({
}))}
/>
)}
{selectedCategory === 'skills' && selectedAgent !== 'opencode' && (
<ProviderSkills
selectedProvider={selectedAgent}
currentProjects={projects.map<SkillsProject>((project) => ({
projectId: project.name,
displayName: project.displayName,
fullPath: project.fullPath,
path: project.path,
}))}
/>
)}
</div>
);
}

View File

@@ -1,11 +1,11 @@
import { useTranslation } from 'react-i18next';
import { cn } from '../../../../../../lib/utils';
import type { AgentCategory } from '../../../../types/types';
import type { AgentCategoryTabsSectionProps } from '../types';
const AGENT_CATEGORIES: AgentCategory[] = ['account', 'permissions', 'mcp'];
export default function AgentCategoryTabsSection({
categories,
selectedAgent,
selectedCategory,
onSelectCategory,
}: AgentCategoryTabsSectionProps) {
@@ -14,7 +14,7 @@ export default function AgentCategoryTabsSection({
return (
<div className="flex-shrink-0 border-b border-border">
<div role="tablist" className="flex overflow-x-auto px-2 md:px-4">
{AGENT_CATEGORIES.map((category) => (
{categories.map((category) => (
<button
key={category}
role="tab"
@@ -30,6 +30,9 @@ export default function AgentCategoryTabsSection({
{category === 'account' && t('tabs.account')}
{category === 'permissions' && t('tabs.permissions')}
{category === 'mcp' && t('tabs.mcpServers')}
{category === 'skills' && t('tabs.skills', {
defaultValue: selectedAgent === 'opencode' ? 'Shared Skills' : 'Skills',
})}
</button>
))}
</div>

View File

@@ -32,6 +32,8 @@ export type AgentsSettingsTabProps = {
};
export type AgentCategoryTabsSectionProps = {
categories: AgentCategory[];
selectedAgent: AgentProvider;
selectedCategory: AgentCategory;
onSelectCategory: (category: AgentCategory) => void;
};