mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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:
parent
9bb05adc74
commit
039ad8afd7
11
desktop/src/api/skills.ts
Normal file
11
desktop/src/api/skills.ts
Normal 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)}`,
|
||||
),
|
||||
}
|
||||
@ -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.',
|
||||
|
||||
@ -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': '选择应用程序的显示语言。',
|
||||
|
||||
51
desktop/src/stores/skillStore.ts
Normal file
51
desktop/src/stores/skillStore.ts
Normal 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 }),
|
||||
}))
|
||||
33
desktop/src/types/skill.ts
Normal file
33
desktop/src/types/skill.ts
Normal 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
267
src/server/api/skills.ts
Normal 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 },
|
||||
})
|
||||
}
|
||||
@ -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)
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user