mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
1108 lines
32 KiB
Markdown
1108 lines
32 KiB
Markdown
# 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<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 },
|
|
})
|
|
}
|
|
```
|
|
|
|
- [ ] **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<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 }),
|
|
}))
|
|
```
|
|
|
|
- [ ] **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<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>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **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<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'
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **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
|
|
<TabButton icon="auto_awesome" label={t('settings.tab.skills')} active={activeTab === 'skills'} onClick={() => setActiveTab('skills')} />
|
|
```
|
|
|
|
**4. Add the content renderer** (after the adapters condition, around line 35):
|
|
|
|
```tsx
|
|
{activeTab === 'skills' && <SkillSettings />}
|
|
```
|
|
|
|
**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 (
|
|
<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>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **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)"
|
|
```
|