feat(skills): add server API, types, client, store, and i18n

- Server: GET /api/skills (list) and GET /api/skills/detail (tree + files)
- Desktop: type definitions, API client, Zustand store
- i18n: EN/ZH translation keys for Skills tab

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-04-09 11:30:16 +08:00
parent 9bb05adc74
commit 039ad8afd7
7 changed files with 394 additions and 0 deletions

11
desktop/src/api/skills.ts Normal file
View File

@ -0,0 +1,11 @@
import { api } from './client'
import type { SkillMeta, SkillDetail } from '../types/skill'
export const skillsApi = {
list: () => api.get<{ skills: SkillMeta[] }>('/api/skills'),
detail: (source: string, name: string) =>
api.get<{ detail: SkillDetail }>(
`/api/skills/detail?source=${encodeURIComponent(source)}&name=${encodeURIComponent(name)}`,
),
}

View File

@ -47,6 +47,7 @@ export const en = {
'settings.tab.providers': 'Providers',
'settings.tab.permissions': 'Permissions',
'settings.tab.general': 'General',
'settings.tab.skills': 'Skills',
// Settings > Providers
'settings.providers.title': 'Providers',
@ -139,6 +140,19 @@ export const en = {
'settings.adapters.platform.telegram': 'Telegram',
'settings.adapters.platform.feishu': 'Feishu',
// Settings > Skills
'settings.skills.title': 'Installed Skills',
'settings.skills.description': 'Skills extend Claude with specialized capabilities. Manage skills in ~/.claude/skills/',
'settings.skills.empty': 'No skills installed',
'settings.skills.emptyHint': 'Add skills to ~/.claude/skills/ to get started',
'settings.skills.back': 'Back to list',
'settings.skills.files': 'files',
'settings.skills.source.user': 'User',
'settings.skills.source.project': 'Project',
'settings.skills.source.plugin': 'Plugin',
'settings.skills.source.mcp': 'MCP',
'settings.skills.source.bundled': 'Built-in',
// Settings > General
'settings.general.languageTitle': 'Language',
'settings.general.languageDescription': 'Choose the display language for the application.',

View File

@ -49,6 +49,7 @@ export const zh: Record<TranslationKey, string> = {
'settings.tab.providers': '服务商',
'settings.tab.permissions': '权限',
'settings.tab.general': '通用',
'settings.tab.skills': '技能',
// Settings > Providers
'settings.providers.title': '服务商',
@ -141,6 +142,19 @@ export const zh: Record<TranslationKey, string> = {
'settings.adapters.platform.telegram': 'Telegram',
'settings.adapters.platform.feishu': '飞书',
// Settings > Skills
'settings.skills.title': '已安装技能',
'settings.skills.description': '技能扩展 Claude 的能力。在 ~/.claude/skills/ 中管理技能。',
'settings.skills.empty': '暂无已安装技能',
'settings.skills.emptyHint': '在 ~/.claude/skills/ 中添加技能即可开始',
'settings.skills.back': '返回列表',
'settings.skills.files': '个文件',
'settings.skills.source.user': '用户',
'settings.skills.source.project': '项目',
'settings.skills.source.plugin': '插件',
'settings.skills.source.mcp': 'MCP',
'settings.skills.source.bundled': '内置',
// Settings > General
'settings.general.languageTitle': '语言',
'settings.general.languageDescription': '选择应用程序的显示语言。',

View File

@ -0,0 +1,51 @@
import { create } from 'zustand'
import { skillsApi } from '../api/skills'
import type { SkillMeta, SkillDetail } from '../types/skill'
type SkillStore = {
skills: SkillMeta[]
selectedSkill: SkillDetail | null
isLoading: boolean
isDetailLoading: boolean
error: string | null
fetchSkills: () => Promise<void>
fetchSkillDetail: (source: string, name: string) => Promise<void>
clearSelection: () => void
}
export const useSkillStore = create<SkillStore>((set) => ({
skills: [],
selectedSkill: null,
isLoading: false,
isDetailLoading: false,
error: null,
fetchSkills: async () => {
set({ isLoading: true, error: null })
try {
const { skills } = await skillsApi.list()
set({ skills, isLoading: false })
} catch (err) {
set({
error: err instanceof Error ? err.message : String(err),
isLoading: false,
})
}
},
fetchSkillDetail: async (source, name) => {
set({ isDetailLoading: true, error: null })
try {
const { detail } = await skillsApi.detail(source, name)
set({ selectedSkill: detail, isDetailLoading: false })
} catch (err) {
set({
error: err instanceof Error ? err.message : String(err),
isDetailLoading: false,
})
}
},
clearSelection: () => set({ selectedSkill: null }),
}))

View File

@ -0,0 +1,33 @@
export type SkillSource = 'user' | 'project' | 'plugin' | 'mcp' | 'bundled'
export type SkillMeta = {
name: string
displayName?: string
description: string
source: SkillSource
userInvocable: boolean
version?: string
contentLength: number
hasDirectory: boolean
pluginName?: string
}
export type FileTreeNode = {
name: string
path: string
type: 'file' | 'directory'
children?: FileTreeNode[]
}
export type SkillFile = {
path: string
content: string
language: string
}
export type SkillDetail = {
meta: SkillMeta
tree: FileTreeNode[]
files: SkillFile[]
skillRoot: string
}

267
src/server/api/skills.ts Normal file
View File

@ -0,0 +1,267 @@
/**
* Skills REST API
*
* GET /api/skills List all installed skills (metadata only)
* GET /api/skills/detail Full skill data (tree + files)
* ?source=user&name=xxx
*/
import * as os from 'os'
import * as path from 'path'
import * as fs from 'fs/promises'
import { parse as parseYaml } from 'yaml'
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
// ─── Types ───────────────────────────────────────────────────────────────────
type SkillMeta = {
name: string
displayName?: string
description: string
source: 'user' | 'project'
userInvocable: boolean
version?: string
contentLength: number
hasDirectory: boolean
}
type FileTreeNode = {
name: string
path: string
type: 'file' | 'directory'
children?: FileTreeNode[]
}
type SkillFile = {
path: string
content: string
language: string
}
// ─── Constants ───────────────────────────────────────────────────────────────
const MAX_FILES = 50
const MAX_FILE_SIZE = 100 * 1024 // 100 KB
const SKIP_ENTRIES = new Set(['node_modules', '.git', '__pycache__', '.DS_Store'])
const LANG_MAP: Record<string, string> = {
md: 'markdown', ts: 'typescript', tsx: 'typescript',
js: 'javascript', jsx: 'javascript', json: 'json',
yaml: 'yaml', yml: 'yaml', sh: 'bash', bash: 'bash',
py: 'python', toml: 'toml', css: 'css', html: 'html',
txt: 'text', xml: 'xml', sql: 'sql', rs: 'rust', go: 'go',
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function detectLanguage(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase() || ''
return LANG_MAP[ext] || 'text'
}
function parseFrontmatter(content: string): {
frontmatter: Record<string, unknown>
body: string
} {
const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n?/)
if (!match) return { frontmatter: {}, body: content }
try {
const frontmatter = (parseYaml(match[1]) as Record<string, unknown>) || {}
return { frontmatter, body: content.slice(match[0].length) }
} catch {
return { frontmatter: {}, body: content }
}
}
function getUserSkillsDir(): string {
return path.join(os.homedir(), '.claude', 'skills')
}
async function loadSkillMeta(
skillDir: string,
skillName: string,
source: 'user' | 'project',
): Promise<SkillMeta | null> {
const skillFile = path.join(skillDir, 'SKILL.md')
try {
const raw = await fs.readFile(skillFile, 'utf-8')
const { frontmatter, body } = parseFrontmatter(raw)
const description =
(frontmatter.description as string) ||
body
.split('\n')
.find((l) => l.trim().length > 0)
?.trim() ||
'No description'
return {
name: skillName,
displayName: (frontmatter.name as string) || undefined,
description,
source,
userInvocable: frontmatter['user-invocable'] !== false,
version: frontmatter.version != null ? String(frontmatter.version) : undefined,
contentLength: raw.length,
hasDirectory: true,
}
} catch {
return null
}
}
async function buildFileTree(
dirPath: string,
): Promise<{ tree: FileTreeNode[]; files: SkillFile[] }> {
const tree: FileTreeNode[] = []
const files: SkillFile[] = []
let fileCount = 0
async function walk(currentPath: string, nodes: FileTreeNode[]) {
if (fileCount >= MAX_FILES) return
let entries: import('fs').Dirent[]
try {
entries = await fs.readdir(currentPath, { withFileTypes: true })
} catch {
return
}
// directories first, then alphabetical
entries.sort((a, b) => {
if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1
return a.name.localeCompare(b.name)
})
for (const entry of entries) {
if (fileCount >= MAX_FILES) break
if (SKIP_ENTRIES.has(entry.name) || entry.name.startsWith('.')) continue
const fullPath = path.join(currentPath, entry.name)
const relPath = path.relative(dirPath, fullPath)
if (entry.isDirectory()) {
const node: FileTreeNode = {
name: entry.name,
path: relPath,
type: 'directory',
children: [],
}
nodes.push(node)
await walk(fullPath, node.children!)
if (node.children!.length === 0) delete node.children
} else if (entry.isFile()) {
nodes.push({ name: entry.name, path: relPath, type: 'file' })
try {
const stat = await fs.stat(fullPath)
if (stat.size <= MAX_FILE_SIZE) {
const content = await fs.readFile(fullPath, 'utf-8')
files.push({
path: relPath,
content,
language: detectLanguage(entry.name),
})
fileCount++
}
} catch {
// skip unreadable files
}
}
}
}
await walk(dirPath, tree)
return { tree, files }
}
// ─── Router ──────────────────────────────────────────────────────────────────
export async function handleSkillsApi(
req: Request,
url: URL,
segments: string[],
): Promise<Response> {
try {
if (req.method !== 'GET') {
throw new ApiError(405, `Method ${req.method} not allowed`, 'METHOD_NOT_ALLOWED')
}
const sub = segments[2]
switch (sub) {
case undefined:
return await listSkills()
case 'detail':
return await getSkillDetail(url)
default:
throw ApiError.notFound(`Unknown skills endpoint: ${sub}`)
}
} catch (error) {
return errorResponse(error)
}
}
// ─── Handlers ────────────────────────────────────────────────────────────────
async function listSkills(): Promise<Response> {
const skillsDir = getUserSkillsDir()
const skills: SkillMeta[] = []
try {
const entries = await fs.readdir(skillsDir, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith('.')) continue
const meta = await loadSkillMeta(
path.join(skillsDir, entry.name),
entry.name,
'user',
)
if (meta) skills.push(meta)
}
} catch {
// skills dir doesn't exist — return empty list
}
skills.sort((a, b) => a.name.localeCompare(b.name))
return Response.json({ skills })
}
async function getSkillDetail(url: URL): Promise<Response> {
const source = url.searchParams.get('source')
const name = url.searchParams.get('name')
if (!source || !name) {
throw ApiError.badRequest('Missing required query parameters: source, name')
}
// Prevent path traversal
if (name.includes('..') || name.includes('/') || name.includes('\\')) {
throw ApiError.badRequest('Invalid skill name')
}
let skillDir: string
if (source === 'user') {
skillDir = path.join(getUserSkillsDir(), name)
} else {
throw ApiError.badRequest(`Unsupported source: ${source}`)
}
try {
const stat = await fs.stat(skillDir)
if (!stat.isDirectory()) throw new Error()
} catch {
throw ApiError.notFound(`Skill not found: ${name}`)
}
const meta = await loadSkillMeta(skillDir, name, source as 'user')
if (!meta) {
throw ApiError.notFound(`Skill missing SKILL.md: ${name}`)
}
const { tree, files } = await buildFileTree(skillDir)
return Response.json({
detail: { meta, tree, files, skillRoot: skillDir },
})
}

View File

@ -14,6 +14,7 @@ import { handleTeamsApi } from './api/teams.js'
import { handleFilesystemRoute } from './api/filesystem.js'
import { handleProvidersApi } from './api/providers.js'
import { handleAdaptersApi } from './api/adapters.js'
import { handleSkillsApi } from './api/skills.js'
export async function handleApiRequest(req: Request, url: URL): Promise<Response> {
const path = url.pathname
@ -67,6 +68,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise<Response
case 'adapters':
return handleAdaptersApi(req, url, segments)
case 'skills':
return handleSkillsApi(req, url, segments)
case 'filesystem':
return handleFilesystemRoute(url.pathname, url)