mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
feat: add Skills browser to Desktop Settings page
- Server: GET /api/skills (list) and GET /api/skills/detail (tree + files) - Desktop: Skills tab with grouped list, file tree navigation, Markdown/code preview - i18n: EN/ZH support - Types, API client, Zustand store Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
21ee8609d8
11
desktop/src/api/skills.ts
Normal file
11
desktop/src/api/skills.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { api } from './client'
|
||||
import type { SkillMeta, SkillDetail } from '../types/skill'
|
||||
|
||||
export const skillsApi = {
|
||||
list: () => api.get<{ skills: SkillMeta[] }>('/api/skills'),
|
||||
|
||||
detail: (source: string, name: string) =>
|
||||
api.get<{ detail: SkillDetail }>(
|
||||
`/api/skills/detail?source=${encodeURIComponent(source)}&name=${encodeURIComponent(name)}`,
|
||||
),
|
||||
}
|
||||
221
desktop/src/components/skills/SkillDetail.tsx
Normal file
221
desktop/src/components/skills/SkillDetail.tsx
Normal file
@ -0,0 +1,221 @@
|
||||
import { useState } from 'react'
|
||||
import { useSkillStore } from '../../stores/skillStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
|
||||
import { CodeViewer } from '../chat/CodeViewer'
|
||||
import type { FileTreeNode } from '../../types/skill'
|
||||
|
||||
// ─── Main Component ──────────────────────────────────────────────────────────
|
||||
|
||||
export function SkillDetail() {
|
||||
const { selectedSkill, isDetailLoading, clearSelection } = useSkillStore()
|
||||
const t = useTranslation()
|
||||
const [selectedFile, setSelectedFile] = useState<string>('SKILL.md')
|
||||
|
||||
if (isDetailLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin w-5 h-5 border-2 border-[var(--color-brand)] border-t-transparent rounded-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!selectedSkill) return null
|
||||
|
||||
const { meta, tree, files } = selectedSkill
|
||||
const currentFile = files.find((f) => f.path === selectedFile) || files[0]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Back button */}
|
||||
<div className="mb-3">
|
||||
<button
|
||||
onClick={clearSelection}
|
||||
className="flex items-center gap-1 text-sm text-[var(--color-text-accent)] hover:underline"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">
|
||||
arrow_back
|
||||
</span>
|
||||
{t('settings.skills.back')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Skill header */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-base font-semibold text-[var(--color-text-primary)]">
|
||||
{meta.displayName || meta.name}
|
||||
</h3>
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)] leading-none uppercase">
|
||||
{meta.source}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-[var(--color-text-tertiary)]">
|
||||
{meta.description}
|
||||
</p>
|
||||
<div className="flex gap-3 mt-1 text-xs text-[var(--color-text-tertiary)]">
|
||||
<span>~{Math.ceil(meta.contentLength / 4)} tokens</span>
|
||||
<span>
|
||||
{files.length} {t('settings.skills.files')}
|
||||
</span>
|
||||
{meta.version && <span>v{meta.version}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Two-panel: file tree + content viewer */}
|
||||
<div className="flex flex-1 min-h-0 border border-[var(--color-border)] rounded-xl overflow-hidden">
|
||||
{/* Left: File tree */}
|
||||
<div className="w-[200px] border-r border-[var(--color-border)] overflow-y-auto bg-[var(--color-surface-container-low)] py-1">
|
||||
<TreeView
|
||||
nodes={tree}
|
||||
selectedPath={selectedFile}
|
||||
onSelect={setSelectedFile}
|
||||
depth={0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right: File content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{currentFile && (
|
||||
<div className="flex flex-col">
|
||||
{/* File path header */}
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-[var(--color-border)] bg-[var(--color-surface-container)]">
|
||||
<span className="text-xs text-[var(--color-text-tertiary)] font-mono">
|
||||
{currentFile.path}
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--color-text-tertiary)] uppercase tracking-wider">
|
||||
{currentFile.language}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4">
|
||||
{currentFile.language === 'markdown' ? (
|
||||
<MarkdownRenderer content={currentFile.content} />
|
||||
) : (
|
||||
<CodeViewer
|
||||
code={currentFile.content}
|
||||
language={currentFile.language}
|
||||
maxLines={9999}
|
||||
showLineNumbers
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── File Tree Components ────────────────────────────────────────────────────
|
||||
|
||||
function TreeView({
|
||||
nodes,
|
||||
selectedPath,
|
||||
onSelect,
|
||||
depth,
|
||||
}: {
|
||||
nodes: FileTreeNode[]
|
||||
selectedPath: string
|
||||
onSelect: (path: string) => void
|
||||
depth: number
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{nodes.map((node) => (
|
||||
<TreeItem
|
||||
key={node.path}
|
||||
node={node}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
depth={depth}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function TreeItem({
|
||||
node,
|
||||
selectedPath,
|
||||
onSelect,
|
||||
depth,
|
||||
}: {
|
||||
node: FileTreeNode
|
||||
selectedPath: string
|
||||
onSelect: (path: string) => void
|
||||
depth: number
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
const isSelected = node.path === selectedPath
|
||||
const isDir = node.type === 'directory'
|
||||
|
||||
const icon = isDir
|
||||
? expanded
|
||||
? 'folder_open'
|
||||
: 'folder'
|
||||
: fileIcon(node.name)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => (isDir ? setExpanded(!expanded) : onSelect(node.path))}
|
||||
className={`w-full flex items-center gap-1.5 px-2 py-1 text-left text-xs transition-colors ${
|
||||
isSelected
|
||||
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)] font-medium'
|
||||
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
style={{ paddingLeft: `${8 + depth * 16}px` }}
|
||||
>
|
||||
{isDir ? (
|
||||
<span className="material-symbols-outlined text-[12px] text-[var(--color-text-tertiary)]">
|
||||
{expanded ? 'expand_more' : 'chevron_right'}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ width: 12 }} />
|
||||
)}
|
||||
<span className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)]">
|
||||
{icon}
|
||||
</span>
|
||||
<span className="truncate">{node.name}</span>
|
||||
</button>
|
||||
|
||||
{isDir && expanded && node.children && (
|
||||
<TreeView
|
||||
nodes={node.children}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function fileIcon(filename: string): string {
|
||||
const ext = filename.split('.').pop()?.toLowerCase()
|
||||
switch (ext) {
|
||||
case 'md':
|
||||
return 'description'
|
||||
case 'ts':
|
||||
case 'tsx':
|
||||
case 'js':
|
||||
case 'jsx':
|
||||
case 'py':
|
||||
case 'rs':
|
||||
case 'go':
|
||||
return 'code'
|
||||
case 'json':
|
||||
case 'yaml':
|
||||
case 'yml':
|
||||
case 'toml':
|
||||
return 'data_object'
|
||||
case 'sh':
|
||||
case 'bash':
|
||||
return 'terminal'
|
||||
default:
|
||||
return 'draft'
|
||||
}
|
||||
}
|
||||
122
desktop/src/components/skills/SkillList.tsx
Normal file
122
desktop/src/components/skills/SkillList.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useSkillStore } from '../../stores/skillStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { SkillMeta, SkillSource } from '../../types/skill'
|
||||
|
||||
const SOURCE_ORDER: SkillSource[] = ['user', 'project', 'plugin', 'mcp', 'bundled']
|
||||
|
||||
const SOURCE_ICONS: Record<SkillSource, string> = {
|
||||
user: 'person',
|
||||
project: 'folder',
|
||||
plugin: 'extension',
|
||||
mcp: 'hub',
|
||||
bundled: 'inventory_2',
|
||||
}
|
||||
|
||||
export function SkillList() {
|
||||
const { skills, isLoading, error, fetchSkills, fetchSkillDetail } =
|
||||
useSkillStore()
|
||||
const t = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
fetchSkills()
|
||||
}, [fetchSkills])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center py-12">
|
||||
<div className="animate-spin w-5 h-5 border-2 border-[var(--color-brand)] border-t-transparent rounded-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="text-sm text-[var(--color-error)] py-4">{error}</div>
|
||||
}
|
||||
|
||||
if (skills.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<span className="material-symbols-outlined text-[40px] text-[var(--color-text-tertiary)] mb-2 block">
|
||||
auto_awesome
|
||||
</span>
|
||||
<p className="text-sm text-[var(--color-text-tertiary)]">
|
||||
{t('settings.skills.empty')}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">
|
||||
{t('settings.skills.emptyHint')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Group by source
|
||||
const grouped: Partial<Record<SkillSource, SkillMeta[]>> = {}
|
||||
for (const skill of skills) {
|
||||
const src = skill.source as SkillSource
|
||||
;(grouped[src] ??= []).push(skill)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{SOURCE_ORDER.map((source) => {
|
||||
const group = grouped[source]
|
||||
if (!group?.length) return null
|
||||
|
||||
return (
|
||||
<div key={source}>
|
||||
{/* Group header */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="material-symbols-outlined text-[16px] text-[var(--color-text-tertiary)]">
|
||||
{SOURCE_ICONS[source]}
|
||||
</span>
|
||||
<span className="text-xs font-semibold text-[var(--color-text-tertiary)] uppercase tracking-wider">
|
||||
{t(`settings.skills.source.${source}`)}
|
||||
</span>
|
||||
<span className="text-xs text-[var(--color-text-tertiary)]">
|
||||
({group.length})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Skill items */}
|
||||
<div className="flex flex-col gap-1">
|
||||
{group.map((skill) => (
|
||||
<button
|
||||
key={`${skill.source}-${skill.name}`}
|
||||
onClick={() =>
|
||||
skill.hasDirectory &&
|
||||
fetchSkillDetail(skill.source, skill.name)
|
||||
}
|
||||
disabled={!skill.hasDirectory}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg border border-[var(--color-border)] hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] transition-all text-left group disabled:opacity-60 disabled:cursor-default disabled:hover:bg-transparent disabled:hover:border-[var(--color-border)]"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)]">
|
||||
auto_awesome
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-[var(--color-text-primary)] truncate">
|
||||
{skill.displayName || skill.name}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--color-text-tertiary)] truncate mt-0.5">
|
||||
{skill.description}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="text-[10px] text-[var(--color-text-tertiary)]">
|
||||
~{Math.ceil(skill.contentLength / 4)} tokens
|
||||
</span>
|
||||
{skill.hasDirectory && (
|
||||
<span className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)]">
|
||||
chevron_right
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -47,6 +47,7 @@ export const en = {
|
||||
'settings.tab.providers': 'Providers',
|
||||
'settings.tab.permissions': 'Permissions',
|
||||
'settings.tab.general': 'General',
|
||||
'settings.tab.skills': 'Skills',
|
||||
|
||||
// Settings > Providers
|
||||
'settings.providers.title': 'Providers',
|
||||
@ -153,6 +154,19 @@ export const en = {
|
||||
'settings.agents.backToList': 'Back to list',
|
||||
'settings.agents.agentCount': '{count} agents',
|
||||
|
||||
// Settings > Skills
|
||||
'settings.skills.title': 'Installed Skills',
|
||||
'settings.skills.description': 'Skills extend Claude with specialized capabilities. Manage skills in ~/.claude/skills/',
|
||||
'settings.skills.empty': 'No skills installed',
|
||||
'settings.skills.emptyHint': 'Add skills to ~/.claude/skills/ to get started',
|
||||
'settings.skills.back': 'Back to list',
|
||||
'settings.skills.files': 'files',
|
||||
'settings.skills.source.user': 'User',
|
||||
'settings.skills.source.project': 'Project',
|
||||
'settings.skills.source.plugin': 'Plugin',
|
||||
'settings.skills.source.mcp': 'MCP',
|
||||
'settings.skills.source.bundled': 'Built-in',
|
||||
|
||||
// Settings > General
|
||||
'settings.general.languageTitle': 'Language',
|
||||
'settings.general.languageDescription': 'Choose the display language for the application.',
|
||||
|
||||
@ -49,6 +49,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.tab.providers': '服务商',
|
||||
'settings.tab.permissions': '权限',
|
||||
'settings.tab.general': '通用',
|
||||
'settings.tab.skills': '技能',
|
||||
|
||||
// Settings > Providers
|
||||
'settings.providers.title': '服务商',
|
||||
@ -155,6 +156,19 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.agents.backToList': '返回列表',
|
||||
'settings.agents.agentCount': '{count} 个 Agent',
|
||||
|
||||
// Settings > Skills
|
||||
'settings.skills.title': '已安装技能',
|
||||
'settings.skills.description': '技能扩展 Claude 的能力。在 ~/.claude/skills/ 中管理技能。',
|
||||
'settings.skills.empty': '暂无已安装技能',
|
||||
'settings.skills.emptyHint': '在 ~/.claude/skills/ 中添加技能即可开始',
|
||||
'settings.skills.back': '返回列表',
|
||||
'settings.skills.files': '个文件',
|
||||
'settings.skills.source.user': '用户',
|
||||
'settings.skills.source.project': '项目',
|
||||
'settings.skills.source.plugin': '插件',
|
||||
'settings.skills.source.mcp': 'MCP',
|
||||
'settings.skills.source.bundled': '内置',
|
||||
|
||||
// Settings > General
|
||||
'settings.general.languageTitle': '语言',
|
||||
'settings.general.languageDescription': '选择应用程序的显示语言。',
|
||||
|
||||
@ -14,8 +14,11 @@ import { AdapterSettings } from './AdapterSettings'
|
||||
import { useAgentStore } from '../stores/agentStore'
|
||||
import type { AgentDefinition } from '../api/agents'
|
||||
import { MarkdownRenderer } from '../components/markdown/MarkdownRenderer'
|
||||
import { useSkillStore } from '../stores/skillStore'
|
||||
import { SkillList } from '../components/skills/SkillList'
|
||||
import { SkillDetail } from '../components/skills/SkillDetail'
|
||||
|
||||
type SettingsTab = 'providers' | 'permissions' | 'general' | 'adapters' | 'agents'
|
||||
type SettingsTab = 'providers' | 'permissions' | 'general' | 'adapters' | 'agents' | 'skills'
|
||||
|
||||
export function Settings() {
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('providers')
|
||||
@ -31,6 +34,7 @@ export function Settings() {
|
||||
<TabButton icon="tune" label={t('settings.tab.general')} active={activeTab === 'general'} onClick={() => setActiveTab('general')} />
|
||||
<TabButton icon="chat" label={t('settings.tab.adapters')} active={activeTab === 'adapters'} onClick={() => setActiveTab('adapters')} />
|
||||
<TabButton icon="smart_toy" label={t('settings.tab.agents')} active={activeTab === 'agents'} onClick={() => setActiveTab('agents')} />
|
||||
<TabButton icon="auto_awesome" label={t('settings.tab.skills')} active={activeTab === 'skills'} onClick={() => setActiveTab('skills')} />
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
@ -40,6 +44,7 @@ export function Settings() {
|
||||
{activeTab === 'general' && <GeneralSettings />}
|
||||
{activeTab === 'adapters' && <AdapterSettings />}
|
||||
{activeTab === 'agents' && <AgentsSettings />}
|
||||
{activeTab === 'skills' && <SkillSettings />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -609,6 +614,7 @@ function GeneralSettings() {
|
||||
)
|
||||
}
|
||||
|
||||
<<<<<<< HEAD
|
||||
// ─── Agents Settings ──────────────────────────────────────
|
||||
|
||||
const AGENT_COLORS: Record<string, string> = {
|
||||
@ -755,3 +761,30 @@ function AgentDetailView({ agent, onBack }: { agent: AgentDefinition; onBack: ()
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Skill Settings ──────────────────────────────────────
|
||||
|
||||
function SkillSettings() {
|
||||
const selectedSkill = useSkillStore((s) => s.selectedSkill)
|
||||
const t = useTranslation()
|
||||
|
||||
if (selectedSkill) {
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<SkillDetail />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">
|
||||
{t('settings.skills.title')}
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--color-text-tertiary)] mb-4">
|
||||
{t('settings.skills.description')}
|
||||
</p>
|
||||
<SkillList />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
51
desktop/src/stores/skillStore.ts
Normal file
51
desktop/src/stores/skillStore.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { create } from 'zustand'
|
||||
import { skillsApi } from '../api/skills'
|
||||
import type { SkillMeta, SkillDetail } from '../types/skill'
|
||||
|
||||
type SkillStore = {
|
||||
skills: SkillMeta[]
|
||||
selectedSkill: SkillDetail | null
|
||||
isLoading: boolean
|
||||
isDetailLoading: boolean
|
||||
error: string | null
|
||||
|
||||
fetchSkills: () => Promise<void>
|
||||
fetchSkillDetail: (source: string, name: string) => Promise<void>
|
||||
clearSelection: () => void
|
||||
}
|
||||
|
||||
export const useSkillStore = create<SkillStore>((set) => ({
|
||||
skills: [],
|
||||
selectedSkill: null,
|
||||
isLoading: false,
|
||||
isDetailLoading: false,
|
||||
error: null,
|
||||
|
||||
fetchSkills: async () => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const { skills } = await skillsApi.list()
|
||||
set({ skills, isLoading: false })
|
||||
} catch (err) {
|
||||
set({
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
isLoading: false,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
fetchSkillDetail: async (source, name) => {
|
||||
set({ isDetailLoading: true, error: null })
|
||||
try {
|
||||
const { detail } = await skillsApi.detail(source, name)
|
||||
set({ selectedSkill: detail, isDetailLoading: false })
|
||||
} catch (err) {
|
||||
set({
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
isDetailLoading: false,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
clearSelection: () => set({ selectedSkill: null }),
|
||||
}))
|
||||
33
desktop/src/types/skill.ts
Normal file
33
desktop/src/types/skill.ts
Normal file
@ -0,0 +1,33 @@
|
||||
export type SkillSource = 'user' | 'project' | 'plugin' | 'mcp' | 'bundled'
|
||||
|
||||
export type SkillMeta = {
|
||||
name: string
|
||||
displayName?: string
|
||||
description: string
|
||||
source: SkillSource
|
||||
userInvocable: boolean
|
||||
version?: string
|
||||
contentLength: number
|
||||
hasDirectory: boolean
|
||||
pluginName?: string
|
||||
}
|
||||
|
||||
export type FileTreeNode = {
|
||||
name: string
|
||||
path: string
|
||||
type: 'file' | 'directory'
|
||||
children?: FileTreeNode[]
|
||||
}
|
||||
|
||||
export type SkillFile = {
|
||||
path: string
|
||||
content: string
|
||||
language: string
|
||||
}
|
||||
|
||||
export type SkillDetail = {
|
||||
meta: SkillMeta
|
||||
tree: FileTreeNode[]
|
||||
files: SkillFile[]
|
||||
skillRoot: string
|
||||
}
|
||||
1107
docs/superpowers/plans/2026-04-09-skill-ui.md
Normal file
1107
docs/superpowers/plans/2026-04-09-skill-ui.md
Normal file
File diff suppressed because it is too large
Load Diff
271
docs/superpowers/specs/2026-04-09-skill-ui-design.md
Normal file
271
docs/superpowers/specs/2026-04-09-skill-ui-design.md
Normal file
@ -0,0 +1,271 @@
|
||||
# Skills UI Feature Design
|
||||
|
||||
## Overview
|
||||
|
||||
Port the CLI `/skills` listing to the Desktop Web APP, adding a full Skills browser with file tree navigation and content preview in the Settings page.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Desktop UI (React) Server (Bun.serve) Filesystem
|
||||
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
|
||||
│ Settings Page │ │ GET /api/skills │ │ ~/.claude/skills/│
|
||||
│ └─ Skills Tab │──────▶│ → list meta │──────▶│ ./.claude/skills/│
|
||||
│ ├─ SkillList │ │ │ │ plugins cache/ │
|
||||
│ └─ SkillDetail │ GET /api/skills/ │ │ bundled skills/ │
|
||||
│ ├─ FileTree│──────▶│ detail?s=&n= │──────▶│ │
|
||||
│ └─ Viewer │ │ → tree + files │ │ │
|
||||
└──────────────────┘ └──────────────────┘ └──────────────────┘
|
||||
```
|
||||
|
||||
## Data Models
|
||||
|
||||
### SkillMeta (list item)
|
||||
|
||||
```typescript
|
||||
type SkillSource = 'userSettings' | 'projectSettings' | 'policySettings'
|
||||
| 'plugin' | 'mcp' | 'bundled'
|
||||
|
||||
type SkillMeta = {
|
||||
name: string // unique name (plugin format: "plugin:skill")
|
||||
displayName?: string // custom display name from frontmatter
|
||||
description: string
|
||||
source: SkillSource
|
||||
userInvocable: boolean
|
||||
version?: string
|
||||
contentLength: number // char count for token estimation
|
||||
pluginName?: string // plugin name when source is 'plugin'
|
||||
hasDirectory: boolean // whether the skill has a browsable directory
|
||||
}
|
||||
```
|
||||
|
||||
### FileTreeNode
|
||||
|
||||
```typescript
|
||||
type FileTreeNode = {
|
||||
name: string // file/dir name
|
||||
path: string // relative to skill root
|
||||
type: 'file' | 'directory'
|
||||
children?: FileTreeNode[] // present for directories
|
||||
}
|
||||
```
|
||||
|
||||
### SkillFile
|
||||
|
||||
```typescript
|
||||
type SkillFile = {
|
||||
path: string // relative path
|
||||
content: string // file content
|
||||
language: string // language id (md, ts, json, yaml, sh, etc.)
|
||||
}
|
||||
```
|
||||
|
||||
### SkillDetail (detail view)
|
||||
|
||||
```typescript
|
||||
type SkillDetail = {
|
||||
meta: SkillMeta
|
||||
tree: FileTreeNode[] // directory tree
|
||||
files: SkillFile[] // all file contents, loaded at once
|
||||
skillRoot: string // absolute path (display only)
|
||||
}
|
||||
```
|
||||
|
||||
## Server API
|
||||
|
||||
### `GET /api/skills`
|
||||
|
||||
Returns metadata list of all installed skills.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"skills": [
|
||||
{
|
||||
"name": "my-skill",
|
||||
"displayName": "My Skill",
|
||||
"description": "Does something useful",
|
||||
"source": "userSettings",
|
||||
"userInvocable": true,
|
||||
"contentLength": 1234,
|
||||
"hasDirectory": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation:** Reuse `loadAllCommands()` from `src/commands.ts`, filter for `type === 'prompt'` commands with skill-related `loadedFrom` values. Extract metadata without loading full content.
|
||||
|
||||
### `GET /api/skills/detail?source={source}&name={name}`
|
||||
|
||||
Returns full skill data including file tree and all file contents.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"detail": {
|
||||
"meta": { ... },
|
||||
"tree": [
|
||||
{ "name": "SKILL.md", "path": "SKILL.md", "type": "file" },
|
||||
{
|
||||
"name": "examples",
|
||||
"path": "examples",
|
||||
"type": "directory",
|
||||
"children": [
|
||||
{ "name": "demo.ts", "path": "examples/demo.ts", "type": "file" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"files": [
|
||||
{ "path": "SKILL.md", "content": "---\nname: ...\n---\n# ...", "language": "md" },
|
||||
{ "path": "examples/demo.ts", "content": "...", "language": "ts" }
|
||||
],
|
||||
"skillRoot": "/Users/x/.claude/skills/my-skill"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation:**
|
||||
1. Find matching skill by source + name from loaded commands
|
||||
2. Get `skillRoot` from the command object
|
||||
3. Recursively walk the directory, skip `node_modules`, `.git`, hidden dirs
|
||||
4. Read all files (max 100KB per file, max 50 files total as safety limit)
|
||||
5. Detect language from file extension
|
||||
6. For MCP skills (no directory): return empty tree, single virtual file with the skill's prompt content
|
||||
|
||||
### File: `src/server/api/skills.ts`
|
||||
|
||||
New file following the pattern of `src/server/api/status.ts`.
|
||||
|
||||
### Router registration: `src/server/router.ts`
|
||||
|
||||
Add `case 'skills': return handleSkillsApi(req, url, segments)`.
|
||||
|
||||
## Desktop UI
|
||||
|
||||
### Settings Tab Addition
|
||||
|
||||
Add a 5th tab "Skills" (icon: `auto_awesome`) to `desktop/src/pages/Settings.tsx`.
|
||||
|
||||
```
|
||||
type SettingsTab = 'providers' | 'permissions' | 'general' | 'adapters' | 'skills'
|
||||
```
|
||||
|
||||
### Skill List View (`SkillList.tsx`)
|
||||
|
||||
- Grouped by source (User, Project, Plugin, MCP, Bundled)
|
||||
- Each group: collapsible header with count
|
||||
- Each item: name, description (truncated), source badge
|
||||
- Click → navigate to detail view
|
||||
- Loading state + empty state
|
||||
|
||||
### Skill Detail View (`SkillDetail.tsx`)
|
||||
|
||||
Two-panel layout:
|
||||
- **Left panel (w-[220px]):** File tree with expand/collapse
|
||||
- **Right panel (flex-1):** File content viewer
|
||||
- **Header:** Back button, skill name, description, source badge, token estimate
|
||||
- Default selected file: SKILL.md
|
||||
|
||||
### File Tree Component (`FileTree.tsx`)
|
||||
|
||||
- Recursive tree with indent
|
||||
- Directory expand/collapse (default: all expanded)
|
||||
- File icons based on extension
|
||||
- Click file → show in right panel
|
||||
- Active file highlight
|
||||
|
||||
### File Viewer (`FileViewer.tsx`)
|
||||
|
||||
- For `.md` files: Use existing `MarkdownRenderer`
|
||||
- For code files: Use existing `CodeViewer` with language detection
|
||||
- File path header with copy button
|
||||
- Scrollable content area
|
||||
|
||||
### Language Detection
|
||||
|
||||
Map file extension to language:
|
||||
```typescript
|
||||
const LANG_MAP: Record<string, string> = {
|
||||
md: 'markdown', ts: 'typescript', tsx: 'typescript',
|
||||
js: 'javascript', jsx: 'javascript', json: 'json',
|
||||
yaml: 'yaml', yml: 'yaml', sh: 'bash', bash: 'bash',
|
||||
py: 'python', toml: 'toml', css: 'css', html: 'html',
|
||||
}
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
### `desktop/src/stores/skillStore.ts`
|
||||
|
||||
```typescript
|
||||
type SkillStoreState = {
|
||||
skills: SkillMeta[]
|
||||
selectedSkill: SkillDetail | null
|
||||
isLoading: boolean
|
||||
isDetailLoading: boolean
|
||||
error: string | null
|
||||
|
||||
fetchSkills: () => Promise<void>
|
||||
fetchSkillDetail: (source: string, name: string) => Promise<void>
|
||||
clearSelection: () => void
|
||||
}
|
||||
```
|
||||
|
||||
### `desktop/src/api/skills.ts`
|
||||
|
||||
```typescript
|
||||
export const skillsApi = {
|
||||
list: () => api.get<{ skills: SkillMeta[] }>('/api/skills'),
|
||||
detail: (source: string, name: string) =>
|
||||
api.get<{ detail: SkillDetail }>(`/api/skills/detail?source=${encodeURIComponent(source)}&name=${encodeURIComponent(name)}`),
|
||||
}
|
||||
```
|
||||
|
||||
### `desktop/src/types/skill.ts`
|
||||
|
||||
All type definitions from Data Models section.
|
||||
|
||||
## i18n
|
||||
|
||||
Add to both `en.ts` and `zh.ts`:
|
||||
|
||||
```typescript
|
||||
'settings.tab.skills': 'Skills' / '技能',
|
||||
'settings.skills.title': 'Installed Skills' / '已安装技能',
|
||||
'settings.skills.description': 'Skills extend Claude...' / '技能扩展 Claude...',
|
||||
'settings.skills.empty': 'No skills installed' / '暂无已安装技能',
|
||||
'settings.skills.back': 'Back to list' / '返回列表',
|
||||
'settings.skills.tokens': '~{count} tokens',
|
||||
'settings.skills.files': '{count} files' / '{count} 个文件',
|
||||
'settings.skills.source.userSettings': 'User' / '用户',
|
||||
'settings.skills.source.projectSettings': 'Project' / '项目',
|
||||
'settings.skills.source.plugin': 'Plugin' / '插件',
|
||||
'settings.skills.source.mcp': 'MCP',
|
||||
'settings.skills.source.bundled': 'Built-in' / '内置',
|
||||
```
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
### New files (7):
|
||||
1. `src/server/api/skills.ts` — Server API handler
|
||||
2. `desktop/src/types/skill.ts` — Type definitions
|
||||
3. `desktop/src/api/skills.ts` — API client
|
||||
4. `desktop/src/stores/skillStore.ts` — Zustand store
|
||||
5. `desktop/src/components/skills/SkillList.tsx` — Skill list component
|
||||
6. `desktop/src/components/skills/SkillDetail.tsx` — Detail view (FileTree + FileViewer inline)
|
||||
|
||||
### Modified files (4):
|
||||
7. `src/server/router.ts` — Add skills route
|
||||
8. `desktop/src/pages/Settings.tsx` — Add Skills tab
|
||||
9. `desktop/src/i18n/locales/en.ts` — English translations
|
||||
10. `desktop/src/i18n/locales/zh.ts` — Chinese translations
|
||||
|
||||
## Design Decisions
|
||||
|
||||
1. **One detail request loads all files** — Single skill directories are small (typically <50KB total), so loading all at once avoids per-file request overhead.
|
||||
2. **Reuse existing components** — `MarkdownRenderer` for .md files, `CodeViewer` for code. No new rendering dependencies.
|
||||
3. **source + name as identifier** — Skills can have duplicate names across sources, so both are needed.
|
||||
4. **Server-side skill loading** — Reuse `loadAllCommands()` which already handles all 6 sources with deduplication and caching.
|
||||
5. **Safety limits** — Max 50 files, 100KB per file to prevent issues with massive plugin directories.
|
||||
6. **FileTree + FileViewer inline in SkillDetail** — No need for separate components given the simplicity; keeps code collocated.
|
||||
267
src/server/api/skills.ts
Normal file
267
src/server/api/skills.ts
Normal file
@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Skills REST API
|
||||
*
|
||||
* GET /api/skills — List all installed skills (metadata only)
|
||||
* GET /api/skills/detail — Full skill data (tree + files)
|
||||
* ?source=user&name=xxx
|
||||
*/
|
||||
|
||||
import * as os from 'os'
|
||||
import * as path from 'path'
|
||||
import * as fs from 'fs/promises'
|
||||
import { parse as parseYaml } from 'yaml'
|
||||
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type SkillMeta = {
|
||||
name: string
|
||||
displayName?: string
|
||||
description: string
|
||||
source: 'user' | 'project'
|
||||
userInvocable: boolean
|
||||
version?: string
|
||||
contentLength: number
|
||||
hasDirectory: boolean
|
||||
}
|
||||
|
||||
type FileTreeNode = {
|
||||
name: string
|
||||
path: string
|
||||
type: 'file' | 'directory'
|
||||
children?: FileTreeNode[]
|
||||
}
|
||||
|
||||
type SkillFile = {
|
||||
path: string
|
||||
content: string
|
||||
language: string
|
||||
}
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
const MAX_FILES = 50
|
||||
const MAX_FILE_SIZE = 100 * 1024 // 100 KB
|
||||
const SKIP_ENTRIES = new Set(['node_modules', '.git', '__pycache__', '.DS_Store'])
|
||||
|
||||
const LANG_MAP: Record<string, string> = {
|
||||
md: 'markdown', ts: 'typescript', tsx: 'typescript',
|
||||
js: 'javascript', jsx: 'javascript', json: 'json',
|
||||
yaml: 'yaml', yml: 'yaml', sh: 'bash', bash: 'bash',
|
||||
py: 'python', toml: 'toml', css: 'css', html: 'html',
|
||||
txt: 'text', xml: 'xml', sql: 'sql', rs: 'rust', go: 'go',
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function detectLanguage(filename: string): string {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || ''
|
||||
return LANG_MAP[ext] || 'text'
|
||||
}
|
||||
|
||||
function parseFrontmatter(content: string): {
|
||||
frontmatter: Record<string, unknown>
|
||||
body: string
|
||||
} {
|
||||
const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n?/)
|
||||
if (!match) return { frontmatter: {}, body: content }
|
||||
try {
|
||||
const frontmatter = (parseYaml(match[1]) as Record<string, unknown>) || {}
|
||||
return { frontmatter, body: content.slice(match[0].length) }
|
||||
} catch {
|
||||
return { frontmatter: {}, body: content }
|
||||
}
|
||||
}
|
||||
|
||||
function getUserSkillsDir(): string {
|
||||
return path.join(os.homedir(), '.claude', 'skills')
|
||||
}
|
||||
|
||||
async function loadSkillMeta(
|
||||
skillDir: string,
|
||||
skillName: string,
|
||||
source: 'user' | 'project',
|
||||
): Promise<SkillMeta | null> {
|
||||
const skillFile = path.join(skillDir, 'SKILL.md')
|
||||
try {
|
||||
const raw = await fs.readFile(skillFile, 'utf-8')
|
||||
const { frontmatter, body } = parseFrontmatter(raw)
|
||||
|
||||
const description =
|
||||
(frontmatter.description as string) ||
|
||||
body
|
||||
.split('\n')
|
||||
.find((l) => l.trim().length > 0)
|
||||
?.trim() ||
|
||||
'No description'
|
||||
|
||||
return {
|
||||
name: skillName,
|
||||
displayName: (frontmatter.name as string) || undefined,
|
||||
description,
|
||||
source,
|
||||
userInvocable: frontmatter['user-invocable'] !== false,
|
||||
version: frontmatter.version != null ? String(frontmatter.version) : undefined,
|
||||
contentLength: raw.length,
|
||||
hasDirectory: true,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function buildFileTree(
|
||||
dirPath: string,
|
||||
): Promise<{ tree: FileTreeNode[]; files: SkillFile[] }> {
|
||||
const tree: FileTreeNode[] = []
|
||||
const files: SkillFile[] = []
|
||||
let fileCount = 0
|
||||
|
||||
async function walk(currentPath: string, nodes: FileTreeNode[]) {
|
||||
if (fileCount >= MAX_FILES) return
|
||||
|
||||
let entries: import('fs').Dirent[]
|
||||
try {
|
||||
entries = await fs.readdir(currentPath, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
// directories first, then alphabetical
|
||||
entries.sort((a, b) => {
|
||||
if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
for (const entry of entries) {
|
||||
if (fileCount >= MAX_FILES) break
|
||||
if (SKIP_ENTRIES.has(entry.name) || entry.name.startsWith('.')) continue
|
||||
|
||||
const fullPath = path.join(currentPath, entry.name)
|
||||
const relPath = path.relative(dirPath, fullPath)
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const node: FileTreeNode = {
|
||||
name: entry.name,
|
||||
path: relPath,
|
||||
type: 'directory',
|
||||
children: [],
|
||||
}
|
||||
nodes.push(node)
|
||||
await walk(fullPath, node.children!)
|
||||
if (node.children!.length === 0) delete node.children
|
||||
} else if (entry.isFile()) {
|
||||
nodes.push({ name: entry.name, path: relPath, type: 'file' })
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(fullPath)
|
||||
if (stat.size <= MAX_FILE_SIZE) {
|
||||
const content = await fs.readFile(fullPath, 'utf-8')
|
||||
files.push({
|
||||
path: relPath,
|
||||
content,
|
||||
language: detectLanguage(entry.name),
|
||||
})
|
||||
fileCount++
|
||||
}
|
||||
} catch {
|
||||
// skip unreadable files
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await walk(dirPath, tree)
|
||||
return { tree, files }
|
||||
}
|
||||
|
||||
// ─── Router ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function handleSkillsApi(
|
||||
req: Request,
|
||||
url: URL,
|
||||
segments: string[],
|
||||
): Promise<Response> {
|
||||
try {
|
||||
if (req.method !== 'GET') {
|
||||
throw new ApiError(405, `Method ${req.method} not allowed`, 'METHOD_NOT_ALLOWED')
|
||||
}
|
||||
|
||||
const sub = segments[2]
|
||||
|
||||
switch (sub) {
|
||||
case undefined:
|
||||
return await listSkills()
|
||||
case 'detail':
|
||||
return await getSkillDetail(url)
|
||||
default:
|
||||
throw ApiError.notFound(`Unknown skills endpoint: ${sub}`)
|
||||
}
|
||||
} catch (error) {
|
||||
return errorResponse(error)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Handlers ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function listSkills(): Promise<Response> {
|
||||
const skillsDir = getUserSkillsDir()
|
||||
const skills: SkillMeta[] = []
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(skillsDir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name.startsWith('.')) continue
|
||||
const meta = await loadSkillMeta(
|
||||
path.join(skillsDir, entry.name),
|
||||
entry.name,
|
||||
'user',
|
||||
)
|
||||
if (meta) skills.push(meta)
|
||||
}
|
||||
} catch {
|
||||
// skills dir doesn't exist — return empty list
|
||||
}
|
||||
|
||||
skills.sort((a, b) => a.name.localeCompare(b.name))
|
||||
return Response.json({ skills })
|
||||
}
|
||||
|
||||
async function getSkillDetail(url: URL): Promise<Response> {
|
||||
const source = url.searchParams.get('source')
|
||||
const name = url.searchParams.get('name')
|
||||
|
||||
if (!source || !name) {
|
||||
throw ApiError.badRequest('Missing required query parameters: source, name')
|
||||
}
|
||||
|
||||
// Prevent path traversal
|
||||
if (name.includes('..') || name.includes('/') || name.includes('\\')) {
|
||||
throw ApiError.badRequest('Invalid skill name')
|
||||
}
|
||||
|
||||
let skillDir: string
|
||||
if (source === 'user') {
|
||||
skillDir = path.join(getUserSkillsDir(), name)
|
||||
} else {
|
||||
throw ApiError.badRequest(`Unsupported source: ${source}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(skillDir)
|
||||
if (!stat.isDirectory()) throw new Error()
|
||||
} catch {
|
||||
throw ApiError.notFound(`Skill not found: ${name}`)
|
||||
}
|
||||
|
||||
const meta = await loadSkillMeta(skillDir, name, source as 'user')
|
||||
if (!meta) {
|
||||
throw ApiError.notFound(`Skill missing SKILL.md: ${name}`)
|
||||
}
|
||||
|
||||
const { tree, files } = await buildFileTree(skillDir)
|
||||
|
||||
return Response.json({
|
||||
detail: { meta, tree, files, skillRoot: skillDir },
|
||||
})
|
||||
}
|
||||
@ -14,6 +14,7 @@ import { handleTeamsApi } from './api/teams.js'
|
||||
import { handleFilesystemRoute } from './api/filesystem.js'
|
||||
import { handleProvidersApi } from './api/providers.js'
|
||||
import { handleAdaptersApi } from './api/adapters.js'
|
||||
import { handleSkillsApi } from './api/skills.js'
|
||||
|
||||
export async function handleApiRequest(req: Request, url: URL): Promise<Response> {
|
||||
const path = url.pathname
|
||||
@ -67,6 +68,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise<Response
|
||||
case 'adapters':
|
||||
return handleAdaptersApi(req, url, segments)
|
||||
|
||||
case 'skills':
|
||||
return handleSkillsApi(req, url, segments)
|
||||
|
||||
case 'filesystem':
|
||||
return handleFilesystemRoute(url.pathname, url)
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user