diff --git a/desktop/src/api/skills.ts b/desktop/src/api/skills.ts new file mode 100644 index 00000000..3ad50b04 --- /dev/null +++ b/desktop/src/api/skills.ts @@ -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)}`, + ), +} diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index b669712b..98fea528 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -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.', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index e9be3840..0c7a1344 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -49,6 +49,7 @@ export const zh: Record = { '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 = { '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': '选择应用程序的显示语言。', diff --git a/desktop/src/stores/skillStore.ts b/desktop/src/stores/skillStore.ts new file mode 100644 index 00000000..0113806e --- /dev/null +++ b/desktop/src/stores/skillStore.ts @@ -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 + 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 }), +})) diff --git a/desktop/src/types/skill.ts b/desktop/src/types/skill.ts new file mode 100644 index 00000000..48cb8b39 --- /dev/null +++ b/desktop/src/types/skill.ts @@ -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 +} diff --git a/src/server/api/skills.ts b/src/server/api/skills.ts new file mode 100644 index 00000000..20bf3db2 --- /dev/null +++ b/src/server/api/skills.ts @@ -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 = { + 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 }, + }) +} diff --git a/src/server/router.ts b/src/server/router.ts index 75d248ac..b2854e47 100644 --- a/src/server/router.ts +++ b/src/server/router.ts @@ -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 { const path = url.pathname @@ -67,6 +68,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise