diff --git a/docs/superpowers/plans/2026-04-09-skill-ui.md b/docs/superpowers/plans/2026-04-09-skill-ui.md deleted file mode 100644 index 9b557eb1..00000000 --- a/docs/superpowers/plans/2026-04-09-skill-ui.md +++ /dev/null @@ -1,1107 +0,0 @@ -# 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 deleted file mode 100644 index d4036988..00000000 --- a/docs/superpowers/specs/2026-04-09-skill-ui-design.md +++ /dev/null @@ -1,271 +0,0 @@ -# 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.