From 9bb05adc740a04d48145fc91fce6f71ce006722e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Thu, 9 Apr 2026 11:18:08 +0800 Subject: [PATCH] docs: add Skills UI design spec and implementation plan Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/superpowers/plans/2026-04-09-skill-ui.md | 1107 +++++++++++++++++ .../specs/2026-04-09-skill-ui-design.md | 271 ++++ 2 files changed, 1378 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-09-skill-ui.md create mode 100644 docs/superpowers/specs/2026-04-09-skill-ui-design.md diff --git a/docs/superpowers/plans/2026-04-09-skill-ui.md b/docs/superpowers/plans/2026-04-09-skill-ui.md new file mode 100644 index 00000000..9b557eb1 --- /dev/null +++ b/docs/superpowers/plans/2026-04-09-skill-ui.md @@ -0,0 +1,1107 @@ +# 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)" +``` diff --git a/docs/superpowers/specs/2026-04-09-skill-ui-design.md b/docs/superpowers/specs/2026-04-09-skill-ui-design.md new file mode 100644 index 00000000..d4036988 --- /dev/null +++ b/docs/superpowers/specs/2026-04-09-skill-ui-design.md @@ -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 = { + 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 + fetchSkillDetail: (source: string, name: string) => Promise + 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.