# Skills UI Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add a Skills browser to the Desktop Web APP Settings page, with API backend, file tree navigation, and Markdown/code content preview. **Architecture:** Server reads skill directories from `~/.claude/skills/`, exposes two REST endpoints (list + detail). Desktop frontend adds a "Skills" tab to Settings with list/detail views, reusing existing `MarkdownRenderer` and `CodeViewer` components. **Tech Stack:** Bun server (REST API), React + Zustand + Tailwind CSS (desktop), marked + react-shiki (rendering) --- ## File Structure | Action | File | Responsibility | |--------|------|----------------| | Create | `src/server/api/skills.ts` | Server API handler: list skills, read skill directory tree + files | | Modify | `src/server/router.ts` | Register `/api/skills` route | | Create | `desktop/src/types/skill.ts` | Shared type definitions | | Create | `desktop/src/api/skills.ts` | API client for skills endpoints | | Create | `desktop/src/stores/skillStore.ts` | Zustand store for skill state | | Create | `desktop/src/components/skills/SkillList.tsx` | Grouped skill list view | | Create | `desktop/src/components/skills/SkillDetail.tsx` | File tree + content viewer | | Modify | `desktop/src/pages/Settings.tsx` | Add Skills tab + SkillSettings wrapper | | Modify | `desktop/src/i18n/locales/en.ts` | English translation keys | | Modify | `desktop/src/i18n/locales/zh.ts` | Chinese translation keys | --- ### Task 1: Desktop Type Definitions **Files:** - Create: `desktop/src/types/skill.ts` - [ ] **Step 1: Create type definitions** ```typescript // desktop/src/types/skill.ts 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 } ``` - [ ] **Step 2: Commit** ```bash git add desktop/src/types/skill.ts git commit -m "feat(skills): add type definitions for Skills UI" ``` --- ### Task 2: Server API Handler **Files:** - Create: `src/server/api/skills.ts` - Modify: `src/server/router.ts:1-79` - [ ] **Step 1: Create the skills API handler** ```typescript // src/server/api/skills.ts /** * 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 (server-local, mirrors desktop/src/types/skill.ts) ──────────────── 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 = { 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 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) || {} 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 { 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 { 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 { 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 { 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 }, }) } ``` - [ ] **Step 2: Register the route in router.ts** In `src/server/router.ts`, add the import and case: ```typescript // Add import at the top (after existing imports): import { handleSkillsApi } from './api/skills.js' // Add case in the switch (before `case 'filesystem':`): case 'skills': return handleSkillsApi(req, url, segments) ``` - [ ] **Step 3: Verify server compiles** Run: `cd /Users/nanmi/workspace/myself_code/claude-code-haha/.claude/worktrees/feature-skill-ui && bun build src/server/index.ts --outdir /tmp/skill-check --target bun 2>&1 | tail -5` Expected: No type errors, successful build output. - [ ] **Step 4: Commit** ```bash git add src/server/api/skills.ts src/server/router.ts git commit -m "feat(skills): add GET /api/skills and /api/skills/detail endpoints" ``` --- ### Task 3: Desktop API Client **Files:** - Create: `desktop/src/api/skills.ts` - [ ] **Step 1: Create the API client** ```typescript // desktop/src/api/skills.ts 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)}`, ), } ``` - [ ] **Step 2: Commit** ```bash git add desktop/src/api/skills.ts git commit -m "feat(skills): add desktop API client for skills endpoints" ``` --- ### Task 4: Zustand Store **Files:** - Create: `desktop/src/stores/skillStore.ts` - [ ] **Step 1: Create the skill store** ```typescript // desktop/src/stores/skillStore.ts 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 fetchSkillDetail: (source: string, name: string) => Promise clearSelection: () => void } export const useSkillStore = create((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 }), })) ``` - [ ] **Step 2: Commit** ```bash git add desktop/src/stores/skillStore.ts git commit -m "feat(skills): add Zustand store for skill state management" ``` --- ### Task 5: i18n Translation Keys **Files:** - Modify: `desktop/src/i18n/locales/en.ts:47-50` - Modify: `desktop/src/i18n/locales/zh.ts:47-50` - [ ] **Step 1: Add English translations** In `desktop/src/i18n/locales/en.ts`, add after `'settings.tab.general': 'General',` (line 49): ```typescript 'settings.tab.skills': 'Skills', ``` Then add a new section after the `// Settings > Adapters` block (before `// Settings > General`). Insert after line 140 (`'settings.adapters.platform.feishu': 'Feishu',`): ```typescript // 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', ``` - [ ] **Step 2: Add Chinese translations** In `desktop/src/i18n/locales/zh.ts`, add after the `settings.tab.general` line (around line 50): ```typescript 'settings.tab.skills': '技能', ``` Then add matching keys after the adapters section: ```typescript // 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': '内置', ``` - [ ] **Step 3: Verify TypeScript is happy** Run: `cd /Users/nanmi/workspace/myself_code/claude-code-haha/.claude/worktrees/feature-skill-ui/desktop && npx tsc --noEmit 2>&1 | tail -5` Expected: No errors (the `zh.ts` Record type enforces key parity with `en.ts`). - [ ] **Step 4: Commit** ```bash git add desktop/src/i18n/locales/en.ts desktop/src/i18n/locales/zh.ts git commit -m "feat(skills): add i18n translation keys for Skills UI" ``` --- ### Task 6: SkillList Component **Files:** - Create: `desktop/src/components/skills/SkillList.tsx` - [ ] **Step 1: Create the skill list component** ```tsx // desktop/src/components/skills/SkillList.tsx 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 = { 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 (
) } if (error) { return
{error}
} if (skills.length === 0) { return (
auto_awesome

{t('settings.skills.empty')}

{t('settings.skills.emptyHint')}

) } // Group by source const grouped: Partial> = {} for (const skill of skills) { const src = skill.source as SkillSource ;(grouped[src] ??= []).push(skill) } return (
{SOURCE_ORDER.map((source) => { const group = grouped[source] if (!group?.length) return null return (
{/* Group header */}
{SOURCE_ICONS[source]} {t(`settings.skills.source.${source}`)} ({group.length})
{/* Skill items */}
{group.map((skill) => ( ))}
) })}
) } ``` - [ ] **Step 2: Commit** ```bash git add desktop/src/components/skills/SkillList.tsx git commit -m "feat(skills): add SkillList component with grouped display" ``` --- ### Task 7: SkillDetail Component **Files:** - Create: `desktop/src/components/skills/SkillDetail.tsx` - [ ] **Step 1: Create the skill detail component with file tree and viewer** ```tsx // desktop/src/components/skills/SkillDetail.tsx 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('SKILL.md') if (isDetailLoading) { return (
) } if (!selectedSkill) return null const { meta, tree, files } = selectedSkill const currentFile = files.find((f) => f.path === selectedFile) || files[0] return (
{/* Back button */}
{/* Skill header */}

{meta.displayName || meta.name}

{meta.source}

{meta.description}

~{Math.ceil(meta.contentLength / 4)} tokens {files.length} {t('settings.skills.files')} {meta.version && v{meta.version}}
{/* Two-panel: file tree + content viewer */}
{/* Left: File tree */}
{/* Right: File content */}
{currentFile && (
{/* File path header */}
{currentFile.path} {currentFile.language}
{/* Content */}
{currentFile.language === 'markdown' ? ( ) : ( )}
)}
) } // ─── File Tree Components ──────────────────────────────────────────────────── function TreeView({ nodes, selectedPath, onSelect, depth, }: { nodes: FileTreeNode[] selectedPath: string onSelect: (path: string) => void depth: number }) { return ( <> {nodes.map((node) => ( ))} ) } 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 (
{isDir && expanded && node.children && ( )}
) } 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' } } ``` - [ ] **Step 2: Commit** ```bash git add desktop/src/components/skills/SkillDetail.tsx git commit -m "feat(skills): add SkillDetail component with file tree and content viewer" ``` --- ### Task 8: Settings Page Integration **Files:** - Modify: `desktop/src/pages/Settings.tsx:1-58` - [ ] **Step 1: Add Skills tab to Settings page** In `desktop/src/pages/Settings.tsx`, apply these changes: **1. Add imports at the top** (after existing imports): ```typescript import { useSkillStore } from '../stores/skillStore' import { SkillList } from '../components/skills/SkillList' import { SkillDetail } from '../components/skills/SkillDetail' ``` **2. Extend the SettingsTab type** (line 15): Change: ```typescript type SettingsTab = 'providers' | 'permissions' | 'general' | 'adapters' ``` To: ```typescript type SettingsTab = 'providers' | 'permissions' | 'general' | 'adapters' | 'skills' ``` **3. Add the TabButton** in the Settings component (after the adapters TabButton, around line 29): ```tsx setActiveTab('skills')} /> ``` **4. Add the content renderer** (after the adapters condition, around line 35): ```tsx {activeTab === 'skills' && } ``` **5. Add the SkillSettings wrapper component** at the bottom of the file (before the closing line, after `GeneralSettings`): ```typescript // ─── Skill Settings ────────────────────────────────────── function SkillSettings() { const selectedSkill = useSkillStore((s) => s.selectedSkill) const t = useTranslation() if (selectedSkill) { return (
) } return (

{t('settings.skills.title')}

{t('settings.skills.description')}

) } ``` - [ ] **Step 2: Verify the desktop app compiles** Run: `cd /Users/nanmi/workspace/myself_code/claude-code-haha/.claude/worktrees/feature-skill-ui/desktop && npx tsc --noEmit 2>&1 | tail -10` Expected: No type errors. - [ ] **Step 3: Commit** ```bash git add desktop/src/pages/Settings.tsx git commit -m "feat(skills): integrate Skills tab into Settings page" ``` --- ### Task 9: Manual Smoke Test - [ ] **Step 1: Create a test skill directory** ```bash mkdir -p ~/.claude/skills/test-skill cat > ~/.claude/skills/test-skill/SKILL.md << 'SKILLEOF' --- name: Test Skill description: A test skill for verifying the Skills UI version: 1.0 user-invocable: true --- # Test Skill This is a test skill to verify the Skills browser works correctly. ## Usage Just invoke `/test-skill` from the chat. ```typescript console.log('Hello from test skill') ``` SKILLEOF cat > ~/.claude/skills/test-skill/helper.ts << 'HELPEREOF' export function greet(name: string): string { return `Hello, ${name}!` } HELPEREOF ``` - [ ] **Step 2: Start the server and verify API** Run: `cd /Users/nanmi/workspace/myself_code/claude-code-haha/.claude/worktrees/feature-skill-ui && bun run src/server/index.ts &` Then test: ```bash curl -s http://127.0.0.1:3456/api/skills | python3 -m json.tool ``` Expected: JSON with `skills` array containing `test-skill` entry. ```bash curl -s "http://127.0.0.1:3456/api/skills/detail?source=user&name=test-skill" | python3 -m json.tool ``` Expected: JSON with `detail` containing `meta`, `tree` (SKILL.md + helper.ts), `files` with content. - [ ] **Step 3: Clean up test data (optional)** ```bash rm -rf ~/.claude/skills/test-skill ``` - [ ] **Step 4: Final commit with all changes** ```bash git add -A git commit -m "feat: add Skills browser to Settings page - Server: GET /api/skills (list) and GET /api/skills/detail (tree + files) - Desktop: Skills tab in Settings with list/detail views - File tree navigation with Markdown and code preview - i18n support (EN/ZH)" ```