fix: harden skill marketplace details

This commit is contained in:
程序员阿江(Relakkes) 2026-07-03 19:37:04 +08:00
parent 4f6508f495
commit 6ae97c020f
16 changed files with 497 additions and 278 deletions

View File

@ -1,12 +1,10 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Settings } from '../pages/Settings'
import { useSkillStore } from '../stores/skillStore'
import { useSettingsStore } from '../stores/settingsStore'
import { useSessionStore } from '../stores/sessionStore'
import { useTabStore, SETTINGS_TAB_ID } from '../stores/tabStore'
import { SKILL_CENTER_TAB_ID, useTabStore } from '../stores/tabStore'
import { useUIStore } from '../stores/uiStore'
vi.mock('../api/agents', () => ({
@ -50,252 +48,42 @@ vi.mock('../stores/agentStore', () => ({
}),
}))
vi.mock('../components/chat/CodeViewer', () => ({
CodeViewer: ({ code }: { code: string }) => <pre data-testid="code-viewer">{code}</pre>,
}))
const MOCK_FETCH_SKILLS = vi.fn()
const MOCK_FETCH_SKILL_DETAIL = vi.fn()
const MOCK_CLEAR_SELECTION = vi.fn()
function switchToSkillsTab() {
fireEvent.click(screen.getByText('Skills'))
}
describe('Settings > Skills tab', () => {
describe('Settings > Skills compatibility entry', () => {
beforeEach(() => {
vi.clearAllMocks()
useSettingsStore.setState({ locale: 'en' })
useSessionStore.setState({
sessions: [
{
id: 'session-1',
title: 'Active session',
createdAt: '2026-04-20T00:00:00.000Z',
modifiedAt: '2026-04-20T00:00:00.000Z',
messageCount: 1,
projectPath: '/workspace/project',
workDir: '/workspace/project',
workDirExists: true,
},
],
activeSessionId: 'session-1',
isLoading: false,
error: null,
})
useTabStore.setState({ tabs: [], activeTabId: null })
useUIStore.setState({ activeSettingsTab: 'providers', pendingSettingsTab: null })
useSkillStore.setState({
skills: [],
selectedSkill: null,
selectedSkillReturnTab: 'skills',
isLoading: false,
isDetailLoading: false,
error: null,
fetchSkills: MOCK_FETCH_SKILLS,
fetchSkillDetail: MOCK_FETCH_SKILL_DETAIL,
clearSelection: MOCK_CLEAR_SELECTION,
useTabStore.setState(useTabStore.getInitialState(), true)
useUIStore.setState({
activeSettingsTab: 'providers',
pendingSettingsTab: null,
})
})
it('renders browser summary and grouped skill cards', () => {
useSkillStore.setState({
skills: [
{
name: 'alpha',
displayName: 'Alpha Skill',
description: 'First skill description',
source: 'user',
userInvocable: true,
version: '1.0.0',
contentLength: 400,
hasDirectory: true,
},
{
name: 'beta',
description: 'Second skill description',
source: 'project',
userInvocable: false,
contentLength: 200,
hasDirectory: true,
},
{
name: 'telegram:access',
displayName: 'Telegram Access',
description: 'Plugin-provided access workflow',
source: 'plugin',
pluginName: 'telegram',
userInvocable: true,
contentLength: 280,
hasDirectory: true,
},
],
})
it('opens the unified Skill Center from the legacy settings tab button', () => {
render(<Settings />)
switchToSkillsTab()
expect(screen.getByText('Browse installed skills')).toBeInTheDocument()
expect(screen.getByText('Skill Browser')).toBeInTheDocument()
expect(screen.getByText('Total skills')).toBeInTheDocument()
expect(screen.getByText('Alpha Skill')).toBeInTheDocument()
expect(screen.getByText('Second skill description')).toBeInTheDocument()
expect(screen.getAllByText('Plugin').length).toBeGreaterThan(0)
expect(screen.getByText('Telegram Access')).toBeInTheDocument()
fireEvent.click(screen.getByText('Skills'))
expect(useTabStore.getState().activeTabId).toBe(SKILL_CENTER_TAB_ID)
expect(useTabStore.getState().tabs).toContainEqual({
sessionId: SKILL_CENTER_TAB_ID,
title: 'Skills',
type: 'skill-center',
status: 'idle',
})
})
it('filters installed skills locally by keyword and clears the search', () => {
useSkillStore.setState({
skills: [
{
name: 'alpha',
displayName: 'Alpha Skill',
description: 'First skill description',
source: 'user',
userInvocable: true,
contentLength: 400,
hasDirectory: true,
},
{
name: 'telegram:access',
displayName: 'Telegram Access',
description: 'Plugin-provided access workflow',
source: 'plugin',
pluginName: 'telegram',
userInvocable: true,
contentLength: 280,
hasDirectory: true,
},
],
it('redirects pending legacy skills settings navigation to the Skill Center', async () => {
useUIStore.setState({
activeSettingsTab: 'providers',
pendingSettingsTab: 'skills',
})
render(<Settings />)
switchToSkillsTab()
const searchInput = screen.getByPlaceholderText('Search skills by name, description, or source...')
fireEvent.change(searchInput, { target: { value: 'telegram' } })
expect(screen.getByText('Telegram Access')).toBeInTheDocument()
expect(screen.queryByText('Alpha Skill')).not.toBeInTheDocument()
expect(screen.getByText('1 of 2 skills match')).toBeInTheDocument()
fireEvent.click(screen.getByLabelText('Clear skill search'))
expect(screen.getByText('Telegram Access')).toBeInTheDocument()
expect(screen.getByText('Alpha Skill')).toBeInTheDocument()
})
it('uses the active session workDir even when settings tab is focused', () => {
const fetchSkills = vi.fn()
useSkillStore.setState({
skills: [],
selectedSkill: null,
isLoading: false,
isDetailLoading: false,
error: null,
fetchSkills,
fetchSkillDetail: MOCK_FETCH_SKILL_DETAIL,
clearSelection: MOCK_CLEAR_SELECTION,
await waitFor(() => {
expect(useTabStore.getState().activeTabId).toBe(SKILL_CENTER_TAB_ID)
})
useTabStore.setState({
activeTabId: SETTINGS_TAB_ID,
tabs: [{ sessionId: SETTINGS_TAB_ID, title: 'Settings', type: 'settings', status: 'idle' }],
})
render(<Settings />)
switchToSkillsTab()
expect(fetchSkills).toHaveBeenCalledWith('/workspace/project')
})
it('opens skill detail with metadata cards and parsed markdown body', () => {
useSkillStore.setState({
selectedSkill: {
meta: {
name: 'alpha',
displayName: 'Alpha Skill',
description: 'First skill description',
source: 'user',
userInvocable: true,
version: '1.0.0',
contentLength: 400,
hasDirectory: true,
},
tree: [
{ name: 'SKILL.md', path: 'SKILL.md', type: 'file' },
{ name: 'run.ts', path: 'run.ts', type: 'file' },
],
files: [
{
path: 'SKILL.md',
content: '# Hello\n\nBody content',
body: '# Hello\n\nBody content',
language: 'markdown',
isEntry: true,
frontmatter: {
description: 'Frontmatter description',
'allowed-tools': ['Read', 'Edit'],
model: 'sonnet',
},
},
{
path: 'run.ts',
content: 'console.log("hello")',
language: 'typescript',
isEntry: false,
},
],
skillRoot: '/tmp/alpha',
},
selectedSkillReturnTab: 'skills',
})
render(<Settings />)
switchToSkillsTab()
expect(screen.getByText('Skill metadata')).toBeInTheDocument()
expect(screen.getByText('/slash')).toBeInTheDocument()
expect(screen.getByText('Frontmatter description')).toBeInTheDocument()
expect(screen.getByText('Read, Edit')).toBeInTheDocument()
expect(screen.getByText('Hello')).toBeInTheDocument()
expect(screen.queryByText(/^---$/)).not.toBeInTheDocument()
})
it('returns to plugins tab when skill detail was opened from plugins', async () => {
useSkillStore.setState({
selectedSkill: {
meta: {
name: 'telegram:access',
displayName: 'Access',
description: 'Plugin skill',
source: 'plugin',
userInvocable: true,
contentLength: 200,
hasDirectory: true,
},
tree: [{ name: 'SKILL.md', path: 'SKILL.md', type: 'file' }],
files: [
{
path: 'SKILL.md',
content: '# Access',
body: '# Access',
language: 'markdown',
isEntry: true,
},
],
skillRoot: '/tmp/telegram-access',
},
selectedSkillReturnTab: 'plugins',
})
render(<Settings />)
switchToSkillsTab()
await act(async () => {
fireEvent.click(screen.getByText('Back to list'))
await Promise.resolve()
})
expect(screen.getByText('Installed Plugins')).toBeInTheDocument()
expect(useUIStore.getState().pendingSettingsTab).toBeNull()
})
})

View File

@ -2,10 +2,13 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import '@testing-library/jest-dom'
const { sessionsApiMock } = vi.hoisted(() => ({
const { sessionsApiMock, skillsApiMock } = vi.hoisted(() => ({
sessionsApiMock: {
getInspection: vi.fn(),
},
skillsApiMock: {
list: vi.fn(),
},
}))
vi.mock('../../api/sessions', async (importOriginal) => {
@ -18,10 +21,17 @@ vi.mock('../../api/sessions', async (importOriginal) => {
}
})
vi.mock('../../api/skills', () => ({
skillsApi: {
list: skillsApiMock.list,
},
}))
import { LocalSlashCommandPanel } from './LocalSlashCommandPanel'
import { useSettingsStore } from '../../stores/settingsStore'
import { useTabStore, SETTINGS_TAB_ID } from '../../stores/tabStore'
import { useTabStore, SETTINGS_TAB_ID, SKILL_CENTER_TAB_ID } from '../../stores/tabStore'
import { useUIStore } from '../../stores/uiStore'
import { useSkillStore } from '../../stores/skillStore'
import type { SessionContextSnapshot, SessionInspectionResponse } from '../../api/sessions'
const baseContext: SessionContextSnapshot = {
@ -76,6 +86,17 @@ describe('LocalSlashCommandPanel memory context', () => {
pendingMemoryPath: null,
pendingSettingsTab: null,
})
useSkillStore.setState({
skills: [],
selectedSkill: null,
selectedSkillReturnTab: 'skills',
isLoading: false,
isDetailLoading: false,
error: null,
fetchSkills: vi.fn(),
fetchSkillDetail: vi.fn().mockResolvedValue(undefined),
clearSelection: vi.fn(),
})
})
it('shows loaded memory files and opens the selected project memory in settings', async () => {
@ -142,4 +163,38 @@ describe('LocalSlashCommandPanel memory context', () => {
expect(useTabStore.getState().activeTabId).toBe(SETTINGS_TAB_ID)
})
})
it('opens skill details in the unified Skill Center', async () => {
const onClose = vi.fn()
const fetchSkillDetail = vi.fn().mockResolvedValue(undefined)
useSkillStore.setState({ fetchSkillDetail })
skillsApiMock.list.mockResolvedValue({
skills: [{
name: 'ppt-generator',
displayName: 'PPT Generator',
description: 'Create slide decks.',
source: 'user',
userInvocable: true,
contentLength: 100,
hasDirectory: true,
}],
})
render(
<LocalSlashCommandPanel
command="skills"
cwd="/workspace/demo"
onClose={onClose}
/>,
)
fireEvent.click(await screen.findByText('/ppt-generator'))
await waitFor(() => {
expect(fetchSkillDetail).toHaveBeenCalledWith('user', 'ppt-generator', '/workspace/demo', 'skills')
expect(useTabStore.getState().activeTabId).toBe(SKILL_CENTER_TAB_ID)
})
expect(useUIStore.getState().pendingSettingsTab).toBeNull()
expect(onClose).toHaveBeenCalled()
})
})

View File

@ -9,7 +9,7 @@ import {
} from '../../api/sessions'
import { useTranslation, type TranslationKey } from '../../i18n'
import { useUIStore } from '../../stores/uiStore'
import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore'
import { SETTINGS_TAB_ID, SKILL_CENTER_TAB_ID, useTabStore } from '../../stores/tabStore'
import { useMcpStore } from '../../stores/mcpStore'
import { useSkillStore } from '../../stores/skillStore'
import type { McpServerRecord } from '../../types/mcp'
@ -954,7 +954,6 @@ function McpPanel({ cwd, onClose }: { cwd?: string; onClose: () => void }) {
function SkillsPanel({ cwd, onClose }: { cwd?: string; onClose: () => void }) {
const t = useTranslation()
const setPendingSettingsTab = useUIStore((s) => s.setPendingSettingsTab)
const fetchSkillDetail = useSkillStore((s) => s.fetchSkillDetail)
const [skills, setSkills] = useState<SkillMeta[] | null>(null)
const [error, setError] = useState<string | null>(null)
@ -995,8 +994,7 @@ function SkillsPanel({ cwd, onClose }: { cwd?: string; onClose: () => void }) {
key={`${skill.source}:${skill.name}`}
onClick={async () => {
await fetchSkillDetail(skill.source, skill.name, cwd, 'skills')
setPendingSettingsTab('skills')
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
useTabStore.getState().openTab(SKILL_CENTER_TAB_ID, t('skillCenter.title'), 'skill-center')
onClose()
}}
className="block w-full border-t border-[var(--color-border)] px-4 py-4 text-left first:border-t-0 hover:bg-[var(--color-surface-hover)]"

View File

@ -0,0 +1,136 @@
import { fireEvent, render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSettingsStore } from '../../stores/settingsStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useSkillStore } from '../../stores/skillStore'
import { SkillList } from './SkillList'
const fetchSkills = vi.fn()
const fetchSkillDetail = vi.fn()
describe('SkillList', () => {
beforeEach(() => {
vi.clearAllMocks()
useSettingsStore.setState({ locale: 'en' })
useSessionStore.setState({
sessions: [
{
id: 'session-1',
title: 'Active session',
createdAt: '2026-04-20T00:00:00.000Z',
modifiedAt: '2026-04-20T00:00:00.000Z',
messageCount: 1,
projectPath: '/workspace/project',
workDir: '/workspace/project',
workDirExists: true,
},
],
activeSessionId: 'session-1',
isLoading: false,
error: null,
})
useSkillStore.setState({
skills: [],
selectedSkill: null,
selectedSkillReturnTab: 'skills',
isLoading: false,
isDetailLoading: false,
error: null,
fetchSkills,
fetchSkillDetail,
clearSelection: vi.fn(),
})
})
it('renders browser summary and grouped skill cards', () => {
useSkillStore.setState({
skills: [
{
name: 'alpha',
displayName: 'Alpha Skill',
description: 'First skill description',
source: 'user',
userInvocable: true,
version: '1.0.0',
contentLength: 400,
hasDirectory: true,
},
{
name: 'beta',
description: 'Second skill description',
source: 'project',
userInvocable: false,
contentLength: 200,
hasDirectory: true,
},
{
name: 'telegram:access',
displayName: 'Telegram Access',
description: 'Plugin-provided access workflow',
source: 'plugin',
pluginName: 'telegram',
userInvocable: true,
contentLength: 280,
hasDirectory: true,
},
],
})
render(<SkillList />)
expect(screen.getByText('Browse installed skills')).toBeInTheDocument()
expect(screen.getByText('Skill Browser')).toBeInTheDocument()
expect(screen.getByText('Total skills')).toBeInTheDocument()
expect(screen.getByText('Alpha Skill')).toBeInTheDocument()
expect(screen.getByText('Second skill description')).toBeInTheDocument()
expect(screen.getAllByText('Plugin').length).toBeGreaterThan(0)
expect(screen.getByText('Telegram Access')).toBeInTheDocument()
})
it('filters installed skills locally by keyword and clears the search', () => {
useSkillStore.setState({
skills: [
{
name: 'alpha',
displayName: 'Alpha Skill',
description: 'First skill description',
source: 'user',
userInvocable: true,
contentLength: 400,
hasDirectory: true,
},
{
name: 'telegram:access',
displayName: 'Telegram Access',
description: 'Plugin-provided access workflow',
source: 'plugin',
pluginName: 'telegram',
userInvocable: true,
contentLength: 280,
hasDirectory: true,
},
],
})
render(<SkillList />)
const searchInput = screen.getByPlaceholderText('Search skills by name, description, or source...')
fireEvent.change(searchInput, { target: { value: 'telegram' } })
expect(screen.getByText('Telegram Access')).toBeInTheDocument()
expect(screen.queryByText('Alpha Skill')).not.toBeInTheDocument()
expect(screen.getByText('1 of 2 skills match')).toBeInTheDocument()
fireEvent.click(screen.getByLabelText('Clear skill search'))
expect(screen.getByText('Telegram Access')).toBeInTheDocument()
expect(screen.getByText('Alpha Skill')).toBeInTheDocument()
})
it('uses the active session workDir for project-scoped skills', () => {
render(<SkillList />)
expect(fetchSkills).toHaveBeenCalledWith('/workspace/project')
})
})

View File

@ -713,6 +713,8 @@ export const en = {
// Settings > Skills
'settings.skills.title': 'Installed Skills',
'settings.skills.description': 'Skills extend Claude with specialized capabilities. Manage skills in ~/.claude/skills/',
'settings.skills.redirectDescription': 'Skills now live in the unified Skill Center, where marketplace and installed skills are managed together.',
'settings.skills.openSkillCenter': 'Open Skill Center',
'settings.skills.browserTitle': 'Browse installed skills',
'settings.skills.browserEyebrow': 'Skill Browser',
'settings.skills.browserDescription': 'Inspect bundled, project, and user skills, compare their scope, and open each skill folder to read its docs and source files.',
@ -2053,6 +2055,12 @@ export const en = {
'skillCenter.marketplace.blockedAction': 'Blocked',
'skillCenter.marketplace.openSource': 'Open source',
'skillCenter.marketplace.openUpstream': 'Open upstream',
'skillCenter.marketplace.confirmTitle': 'Confirm skill install',
'skillCenter.marketplace.confirmBody': 'Review the source and target before installing this skill to your local user skills directory.',
'skillCenter.marketplace.confirmSkill': 'Skill',
'skillCenter.marketplace.confirmTarget': 'Target',
'skillCenter.marketplace.confirmInstall': 'Install skill',
'skillCenter.marketplace.noRiskLabels': 'No risk signals reported',
// ─── Tabs ──────────────────────────────────────
'tabs.close': 'Close',

View File

@ -715,6 +715,8 @@ export const jp: Record<TranslationKey, string> = {
// Settings > Skills
'settings.skills.title': 'インストール済みスキル',
'settings.skills.description': 'スキルは Claude に専門的な機能を追加します。スキルは ~/.claude/skills/ で管理します',
'settings.skills.redirectDescription': 'スキルは統合されたスキルセンターで管理されます。マーケットとインストール済みスキルを同じ場所で扱えます。',
'settings.skills.openSkillCenter': 'スキルセンターを開く',
'settings.skills.browserTitle': 'インストール済みスキルを閲覧',
'settings.skills.browserEyebrow': 'スキルブラウザ',
'settings.skills.browserDescription': 'バンドル、プロジェクト、ユーザーのスキルを確認し、スコープを比較し、各スキルフォルダを開いてドキュメントやソースファイルを読みます。',
@ -2055,6 +2057,12 @@ export const jp: Record<TranslationKey, string> = {
'skillCenter.marketplace.blockedAction': 'インストール不可',
'skillCenter.marketplace.openSource': 'ソースを開く',
'skillCenter.marketplace.openUpstream': '上流を開く',
'skillCenter.marketplace.confirmTitle': 'スキルのインストールを確認',
'skillCenter.marketplace.confirmBody': 'インストール前にソースと対象ディレクトリを確認してください。スキルはローカルのユーザースキルディレクトリに書き込まれます。',
'skillCenter.marketplace.confirmSkill': 'スキル',
'skillCenter.marketplace.confirmTarget': '対象',
'skillCenter.marketplace.confirmInstall': 'スキルをインストール',
'skillCenter.marketplace.noRiskLabels': 'リスクシグナルなし',
// ─── Tabs ──────────────────────────────────────
'tabs.close': '閉じる',

View File

@ -715,6 +715,8 @@ export const kr: Record<TranslationKey, string> = {
// Settings > Skills
'settings.skills.title': '설치된 스킬',
'settings.skills.description': '스킬은 Claude에 전문 기능을 추가합니다. 스킬은 ~/.claude/skills/에서 관리합니다',
'settings.skills.redirectDescription': '스킬은 이제 통합 스킬 센터에서 관리합니다. 마켓과 설치된 스킬을 한 곳에서 볼 수 있습니다.',
'settings.skills.openSkillCenter': '스킬 센터 열기',
'settings.skills.browserTitle': '설치된 스킬 살펴보기',
'settings.skills.browserEyebrow': '스킬 브라우저',
'settings.skills.browserDescription': '번들, 프로젝트, 사용자 스킬을 확인하고 범위를 비교하며, 각 스킬 폴더를 열어 문서와 소스 파일을 읽습니다.',
@ -2055,6 +2057,12 @@ export const kr: Record<TranslationKey, string> = {
'skillCenter.marketplace.blockedAction': '설치 불가',
'skillCenter.marketplace.openSource': '소스 열기',
'skillCenter.marketplace.openUpstream': '업스트림 열기',
'skillCenter.marketplace.confirmTitle': '스킬 설치 확인',
'skillCenter.marketplace.confirmBody': '설치 전에 소스와 대상 디렉터리를 확인하세요. 스킬은 로컬 사용자 스킬 디렉터리에 기록됩니다.',
'skillCenter.marketplace.confirmSkill': '스킬',
'skillCenter.marketplace.confirmTarget': '대상',
'skillCenter.marketplace.confirmInstall': '스킬 설치',
'skillCenter.marketplace.noRiskLabels': '보고된 위험 신호 없음',
// ─── Tabs ──────────────────────────────────────
'tabs.close': '닫기',

View File

@ -715,6 +715,8 @@ export const zh: Record<TranslationKey, string> = {
// Settings > Skills
'settings.skills.title': '已安裝技能',
'settings.skills.description': '技能擴充套件 Claude 的能力。在 ~/.claude/skills/ 中管理技能。',
'settings.skills.redirectDescription': '技能現在統一放在技能中心管理,市場和已安裝技能會放在同一個入口裡。',
'settings.skills.openSkillCenter': '開啟技能中心',
'settings.skills.browserTitle': '瀏覽已安裝技能',
'settings.skills.browserEyebrow': '技能瀏覽器',
'settings.skills.browserDescription': '檢視內建、專案和使用者技能,比較它們的來源與規模,並開啟技能目錄閱讀文件和原始碼檔案。',
@ -2055,6 +2057,12 @@ export const zh: Record<TranslationKey, string> = {
'skillCenter.marketplace.blockedAction': '不可安裝',
'skillCenter.marketplace.openSource': '開啟來源',
'skillCenter.marketplace.openUpstream': '開啟上游',
'skillCenter.marketplace.confirmTitle': '確認安裝技能',
'skillCenter.marketplace.confirmBody': '安裝前請確認來源和目標目錄。技能會寫入本機使用者技能目錄。',
'skillCenter.marketplace.confirmSkill': '技能',
'skillCenter.marketplace.confirmTarget': '目標',
'skillCenter.marketplace.confirmInstall': '安裝技能',
'skillCenter.marketplace.noRiskLabels': '未回報風險訊號',
// ─── Tabs ──────────────────────────────────────
'tabs.close': '關閉',

View File

@ -715,6 +715,8 @@ export const zh: Record<TranslationKey, string> = {
// Settings > Skills
'settings.skills.title': '已安装技能',
'settings.skills.description': '技能扩展 Claude 的能力。在 ~/.claude/skills/ 中管理技能。',
'settings.skills.redirectDescription': '技能现在统一放在技能中心管理,市场和已安装技能会放在同一个入口里。',
'settings.skills.openSkillCenter': '打开技能中心',
'settings.skills.browserTitle': '浏览已安装技能',
'settings.skills.browserEyebrow': '技能浏览器',
'settings.skills.browserDescription': '查看内置、项目和用户技能,比较它们的来源与规模,并打开技能目录阅读文档和源码文件。',
@ -2055,6 +2057,12 @@ export const zh: Record<TranslationKey, string> = {
'skillCenter.marketplace.blockedAction': '不可安装',
'skillCenter.marketplace.openSource': '打开来源',
'skillCenter.marketplace.openUpstream': '打开上游',
'skillCenter.marketplace.confirmTitle': '确认安装技能',
'skillCenter.marketplace.confirmBody': '安装前请确认来源和目标目录。技能会写入本机用户技能目录。',
'skillCenter.marketplace.confirmSkill': '技能',
'skillCenter.marketplace.confirmTarget': '目标',
'skillCenter.marketplace.confirmInstall': '安装技能',
'skillCenter.marketplace.noRiskLabels': '未报告风险信号',
// ─── Tabs ──────────────────────────────────────
'tabs.close': '关闭',

View File

@ -36,9 +36,6 @@ import { useAgentStore } from '../stores/agentStore'
import { useSessionStore } from '../stores/sessionStore'
import type { AgentDefinition, AgentSource } from '../api/agents'
import { MarkdownRenderer } from '../components/markdown/MarkdownRenderer'
import { useSkillStore } from '../stores/skillStore'
import { SkillList } from '../components/skills/SkillList'
import { SkillDetail } from '../components/skills/SkillDetail'
import { usePluginStore } from '../stores/pluginStore'
import { PluginList } from '../components/plugins/PluginList'
import { PluginDetail } from '../components/plugins/PluginDetail'
@ -78,6 +75,7 @@ import {
stripProviderSettingsJsonEnv,
} from '../lib/providerSettingsJson'
import { copyTextToClipboard } from '../components/chat/clipboard'
import { SKILL_CENTER_TAB_ID, useTabStore } from '../stores/tabStore'
const NETWORK_TIMEOUT_MIN_SECONDS = 30
const NETWORK_TIMEOUT_MAX_SECONDS = 1800
@ -207,6 +205,11 @@ export function Settings() {
useUIStore.getState().setPendingSettingsTab(null)
}, [pendingSettingsTab, setActiveTab])
useEffect(() => {
if (activeTab !== 'skills') return
useTabStore.getState().openTab(SKILL_CENTER_TAB_ID, t('skillCenter.title'), 'skill-center')
}, [activeTab, t])
return (
<div className="flex-1 flex flex-col overflow-hidden bg-[var(--color-surface)]">
<div className="flex-1 flex overflow-hidden">
@ -220,7 +223,12 @@ export function Settings() {
<TabButton icon="terminal" label={t('settings.tab.terminal')} active={activeTab === 'terminal'} onClick={() => setActiveTab('terminal')} />
<TabButton icon="dns" label={t('settings.tab.mcp')} active={activeTab === 'mcp'} onClick={() => setActiveTab('mcp')} />
<TabButton icon="smart_toy" label={t('settings.tab.agents')} active={activeTab === 'agents'} onClick={() => setActiveTab('agents')} />
<TabButton icon="auto_awesome" label={t('settings.tab.skills')} active={activeTab === 'skills'} onClick={() => setActiveTab('skills')} />
<TabButton
icon="auto_awesome"
label={t('settings.tab.skills')}
active={activeTab === 'skills'}
onClick={() => useTabStore.getState().openTab(SKILL_CENTER_TAB_ID, t('skillCenter.title'), 'skill-center')}
/>
<TabButton icon="history_edu" label={t('settings.tab.memory')} active={activeTab === 'memory'} onClick={() => setActiveTab('memory')} />
<TabButton icon="extension" label={t('settings.tab.plugins')} active={activeTab === 'plugins'} onClick={() => setActiveTab('plugins')} />
<TabButton icon="mouse" label={t('settings.tab.computerUse')} active={activeTab === 'computerUse'} onClick={() => setActiveTab('computerUse')} />
@ -4321,26 +4329,24 @@ function DetailStat({
// ─── Skill Settings ──────────────────────────────────────
function SkillSettings() {
const selectedSkill = useSkillStore((s) => s.selectedSkill)
const t = useTranslation()
if (selectedSkill) {
return (
<div className="w-full min-w-0">
<SkillDetail />
</div>
)
}
return (
<div className="w-full min-w-0">
<div className="w-full min-w-0 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')}
{t('settings.skills.redirectDescription')}
</p>
<SkillList />
<button
type="button"
onClick={() => useTabStore.getState().openTab(SKILL_CENTER_TAB_ID, t('skillCenter.title'), 'skill-center')}
className="inline-flex h-10 items-center gap-2 rounded-md bg-[var(--color-brand)] px-4 text-sm font-semibold text-white transition-colors hover:bg-[var(--color-brand-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
>
<span className="material-symbols-outlined text-[18px]">auto_awesome</span>
{t('settings.skills.openSkillCenter')}
</button>
</div>
)
}

View File

@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { skillMarketApi } from '../api/skillMarket'
import { useSettingsStore } from '../stores/settingsStore'
import { useSkillMarketStore } from '../stores/skillMarketStore'
import { useSkillStore } from '../stores/skillStore'
import type { SkillMarketDetail, SkillMarketItem } from '../types/skillMarket'
import { SkillCenter } from './SkillCenter'
@ -19,6 +20,16 @@ vi.mock('../components/skills/SkillList', () => ({
SkillList: () => <div data-testid="installed-skill-list">Installed skills</div>,
}))
vi.mock('../components/markdown/MarkdownRenderer', () => ({
MarkdownRenderer: ({ content }: { content: string }) => (
<div data-testid="markdown-renderer">{content}</div>
),
}))
vi.mock('../components/chat/CodeViewer', () => ({
CodeViewer: ({ code }: { code: string }) => <div data-testid="code-viewer">{code}</div>,
}))
const mockedSkillMarketApi = vi.mocked(skillMarketApi)
function makeItem(overrides: Partial<SkillMarketItem> = {}): SkillMarketItem {
@ -66,6 +77,17 @@ describe('SkillCenter', () => {
isInstalling: false,
error: null,
})
useSkillStore.setState({
skills: [],
selectedSkill: null,
selectedSkillReturnTab: 'skills',
isLoading: false,
isDetailLoading: false,
error: null,
fetchSkills: vi.fn(),
fetchSkillDetail: vi.fn(),
clearSelection: vi.fn(() => useSkillStore.setState({ selectedSkill: null })),
})
mockedSkillMarketApi.list.mockResolvedValue({
items: [makeItem()],
nextCursor: null,
@ -95,6 +117,14 @@ describe('SkillCenter', () => {
isInstalling: false,
error: null,
})
useSkillStore.setState({
skills: [],
selectedSkill: null,
selectedSkillReturnTab: 'skills',
isLoading: false,
isDetailLoading: false,
error: null,
})
})
it('loads marketplace cards and opens a detail confirmation panel', async () => {
@ -116,6 +146,16 @@ describe('SkillCenter', () => {
expect(detail).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Install' })).toBeEnabled()
expect(screen.getByText('Open upstream')).toHaveAttribute('href', 'https://github.com/example/ppt-generator')
fireEvent.click(screen.getByRole('button', { name: 'Install' }))
expect(mockedSkillMarketApi.install).not.toHaveBeenCalled()
expect(screen.getByRole('dialog', { name: 'Confirm skill install' })).toBeInTheDocument()
expect(screen.getByText('~/.claude/skills/ppt-generator')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Install skill' }))
await waitFor(() => {
expect(mockedSkillMarketApi.install).toHaveBeenCalledWith('clawhub', 'ppt-generator', '1.0.0')
})
})
it('submits market searches without auto-searching every keystroke', async () => {
@ -166,4 +206,37 @@ describe('SkillCenter', () => {
expect(screen.getByTestId('installed-skill-list')).toBeInTheDocument()
expect(within(screen.getByTestId('skill-mine-tab')).getByText('Installed skills')).toBeInTheDocument()
})
it('opens the mine tab when an installed skill detail is already selected', async () => {
useSkillStore.setState({
selectedSkill: {
meta: {
name: 'local-ppt',
displayName: 'Local PPT',
description: 'Installed local skill',
source: 'user',
userInvocable: true,
contentLength: 80,
hasDirectory: true,
},
tree: [{ name: 'SKILL.md', path: 'SKILL.md', type: 'file' }],
files: [
{
path: 'SKILL.md',
content: '# Local PPT',
body: '# Local PPT',
language: 'markdown',
isEntry: true,
},
],
skillRoot: '/tmp/local-ppt',
},
})
render(<SkillCenter />)
expect(await screen.findByText('Local PPT')).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'Mine' })).toHaveAttribute('aria-selected', 'true')
expect(mockedSkillMarketApi.list).not.toHaveBeenCalled()
})
})

View File

@ -12,8 +12,11 @@ import {
X,
} from 'lucide-react'
import { SkillList } from '../components/skills/SkillList'
import { SkillDetail } from '../components/skills/SkillDetail'
import { ConfirmDialog } from '../components/shared/ConfirmDialog'
import { useTranslation } from '../i18n'
import { useSkillMarketStore } from '../stores/skillMarketStore'
import { useSkillStore } from '../stores/skillStore'
import type {
SkillMarketDetail,
SkillMarketInstallEligibility,
@ -31,7 +34,11 @@ const TRUST_SAFE: SkillMarketTrustState[] = ['clean', 'benign', 'signed', 'offic
export function SkillCenter() {
const t = useTranslation()
const [activeTab, setActiveTab] = useState<SkillCenterTab>('marketplace')
const selectedInstalledSkill = useSkillStore((s) => s.selectedSkill)
const [activeTab, setActiveTab] = useState<SkillCenterTab>(() =>
selectedInstalledSkill ? 'mine' : 'marketplace'
)
const [pendingInstallDetail, setPendingInstallDetail] = useState<SkillMarketDetail | null>(null)
const {
items,
selectedDetail,
@ -52,8 +59,15 @@ export function SkillCenter() {
} = useSkillMarketStore()
useEffect(() => {
if (activeTab !== 'marketplace') return
void fetchItems()
}, [fetchItems, source, sort])
}, [activeTab, fetchItems, source, sort])
useEffect(() => {
if (selectedInstalledSkill) {
setActiveTab('mine')
}
}, [selectedInstalledSkill])
const installedCount = useMemo(
() => items.filter((item) => item.installed).length,
@ -215,14 +229,28 @@ export function SkillCenter() {
detail={selectedDetail}
loading={isDetailLoading}
installing={isInstalling}
onInstall={() => void installSelected()}
onInstall={() => selectedDetail && setPendingInstallDetail(selectedDetail)}
onClose={clearDetail}
/>
</div>
<ConfirmDialog
open={!!pendingInstallDetail}
onClose={() => setPendingInstallDetail(null)}
title={t('skillCenter.marketplace.confirmTitle')}
body={pendingInstallDetail ? <InstallConfirmBody detail={pendingInstallDetail} /> : null}
confirmLabel={t('skillCenter.marketplace.confirmInstall')}
cancelLabel={t('common.cancel')}
confirmVariant="primary"
loading={isInstalling}
onConfirm={async () => {
await installSelected()
setPendingInstallDetail(null)
}}
/>
</section>
) : (
<div role="tabpanel" data-testid="skill-mine-tab">
<SkillList />
{selectedInstalledSkill ? <SkillDetail /> : <SkillList />}
</div>
)}
</main>
@ -230,6 +258,35 @@ export function SkillCenter() {
)
}
function InstallConfirmBody({ detail }: { detail: SkillMarketDetail }) {
const t = useTranslation()
const target = `~/.claude/skills/${detail.slug}`
const riskLabel = detail.riskLabels.length > 0
? detail.riskLabels.map((label) => t(`skillCenter.marketplace.risk.${label}`)).join(', ')
: t('skillCenter.marketplace.noRiskLabels')
return (
<div className="space-y-3 text-sm text-[var(--color-text-secondary)]">
<p className="leading-6">{t('skillCenter.marketplace.confirmBody')}</p>
<dl className="grid gap-2 rounded-md bg-[var(--color-surface-container-low)] p-3 text-xs">
<ConfirmRow label={t('skillCenter.marketplace.confirmSkill')} value={detail.displayName} />
<ConfirmRow label={t('skillCenter.marketplace.sourceLabel')} value={t(`skillCenter.marketplace.source.${detail.source}`)} />
<ConfirmRow label={t('skillCenter.marketplace.confirmTarget')} value={target} />
<ConfirmRow label={t('skillCenter.marketplace.riskLabels')} value={riskLabel} />
</dl>
</div>
)
}
function ConfirmRow({ label, value }: { label: string; value: string }) {
return (
<div className="grid grid-cols-[88px_minmax(0,1fr)] gap-2">
<dt className="text-[var(--color-text-tertiary)]">{label}</dt>
<dd className="break-all text-[var(--color-text-primary)]">{value}</dd>
</div>
)
}
function SkillMarketCard({
item,
active,

View File

@ -90,13 +90,19 @@ describe('skillMarketStore', () => {
})
it('updates source, sort, and query filters', () => {
useSkillMarketStore.setState({ selectedDetail: makeDetail() })
useSkillMarketStore.getState().setSource('clawhub')
expect(useSkillMarketStore.getState().selectedDetail).toBeNull()
useSkillMarketStore.setState({ selectedDetail: makeDetail() })
useSkillMarketStore.getState().setSort('updated')
useSkillMarketStore.getState().setQuery('security')
expect(useSkillMarketStore.getState().source).toBe('clawhub')
expect(useSkillMarketStore.getState().sort).toBe('updated')
expect(useSkillMarketStore.getState().query).toBe('security')
expect(useSkillMarketStore.getState().selectedDetail).toBeNull()
})
it('loads selected marketplace detail', async () => {
@ -111,6 +117,31 @@ describe('skillMarketStore', () => {
expect(useSkillMarketStore.getState().error).toBeNull()
})
it('ignores stale marketplace detail responses', async () => {
let resolveFirst: ((value: { detail: SkillMarketDetail }) => void) | undefined
let resolveSecond: ((value: { detail: SkillMarketDetail }) => void) | undefined
const first = makeDetail({ slug: 'first-skill', displayName: 'First Skill' })
const second = makeDetail({
source: 'skillhub',
slug: 'second-skill',
displayName: 'Second Skill',
})
mockedSkillMarketApi.detail
.mockReturnValueOnce(new Promise((resolve) => { resolveFirst = resolve }))
.mockReturnValueOnce(new Promise((resolve) => { resolveSecond = resolve }))
const firstRequest = useSkillMarketStore.getState().fetchDetail('clawhub', 'first-skill')
const secondRequest = useSkillMarketStore.getState().fetchDetail('skillhub', 'second-skill')
resolveSecond?.({ detail: second })
await secondRequest
expect(useSkillMarketStore.getState().selectedDetail).toEqual(second)
resolveFirst?.({ detail: first })
await firstRequest
expect(useSkillMarketStore.getState().selectedDetail).toEqual(second)
})
it('installs selected detail and refreshes the list and detail', async () => {
const selected = makeDetail({ version: '1.0.0' })
const refreshed = makeDetail({

View File

@ -27,6 +27,8 @@ type SkillMarketStore = {
clearDetail: () => void
}
let detailRequestSeq = 0
export const useSkillMarketStore = create<SkillMarketStore>((set, get) => ({
items: [],
selectedDetail: null,
@ -38,13 +40,20 @@ export const useSkillMarketStore = create<SkillMarketStore>((set, get) => ({
isInstalling: false,
error: null,
setSource: (source) => set({ source }),
setSort: (sort) => set({ sort }),
setSource: (source) => {
detailRequestSeq += 1
set({ source, selectedDetail: null, isDetailLoading: false })
},
setSort: (sort) => {
detailRequestSeq += 1
set({ sort, selectedDetail: null, isDetailLoading: false })
},
setQuery: (query) => set({ query }),
fetchItems: async () => {
const { source, sort, query } = get()
set({ isLoading: true, error: null })
detailRequestSeq += 1
set({ isLoading: true, isDetailLoading: false, selectedDetail: null, error: null })
try {
const result = await skillMarketApi.list({
source,
@ -61,11 +70,15 @@ export const useSkillMarketStore = create<SkillMarketStore>((set, get) => ({
},
fetchDetail: async (source, slug) => {
set({ isDetailLoading: true, error: null })
const requestId = detailRequestSeq + 1
detailRequestSeq = requestId
set({ isDetailLoading: true, selectedDetail: null, error: null })
try {
const { detail } = await skillMarketApi.detail(source, slug)
if (requestId !== detailRequestSeq) return
set({ selectedDetail: detail, isDetailLoading: false })
} catch (err) {
if (requestId !== detailRequestSeq) return
set({
isDetailLoading: false,
error: getErrorMessage(err),
@ -91,7 +104,10 @@ export const useSkillMarketStore = create<SkillMarketStore>((set, get) => ({
}
},
clearDetail: () => set({ selectedDetail: null }),
clearDetail: () => {
detailRequestSeq += 1
set({ selectedDetail: null, isDetailLoading: false })
},
}))
function getErrorMessage(err: unknown): string {

View File

@ -700,6 +700,28 @@ describe('skill market service source selection', () => {
})
})
it('blocks uninstalled detail installs when only list-level ClawHub metadata is available', async () => {
const service = createSkillMarketService({
fetchImpl: async () => Response.json(CLAWHUB_TOP_SKILLS_RESPONSE),
installedSkillNames: new Set(),
})
const detail = await service.getDetail({ source: 'clawhub', slug: 'skill-vetter' })
expect(detail).toMatchObject({
source: 'clawhub',
slug: 'skill-vetter',
trustState: 'clean',
installed: false,
files: [],
riskLabels: [],
installEligibility: {
status: 'blocked',
reason: expect.stringContaining('Full package safety scan'),
},
})
})
it('blocks detail installs when list trust metadata is not installable', async () => {
const service = createSkillMarketService({
fetchImpl: async () => Response.json(SKILLHUB_TOP_SKILLS_RESPONSE),

View File

@ -5,7 +5,6 @@ import type {
SkillMarketItem,
SkillMarketListResult,
SkillMarketSource,
SkillMarketTrustState,
} from './types.js'
export type SkillMarketListSource = 'auto' | 'clawhub' | 'skillhub'
@ -44,13 +43,6 @@ const DEFAULT_LIMIT = 24
const MAX_LIMIT = 100
const CATALOG_CACHE_TTL_MS = 5 * 60 * 1_000
const FAILURE_CACHE_TTL_MS = 60 * 1_000
const DETAIL_INSTALLABLE_TRUST_STATES = new Set<SkillMarketTrustState>([
'clean',
'benign',
'signed',
'official',
])
type CatalogCacheEntry = {
expiresAt: number
result: SkillMarketListResult
@ -242,8 +234,13 @@ function installEligibilityFromListItem(item: SkillMarketItem): SkillMarketDetai
installedSkillName: item.slug,
}
}
if (DETAIL_INSTALLABLE_TRUST_STATES.has(item.trustState)) {
return { status: 'installable' }
if (
item.trustState === 'clean' ||
item.trustState === 'benign' ||
item.trustState === 'signed' ||
item.trustState === 'official'
) {
return { status: 'blocked', reason: 'Full package safety scan is required before install.' }
}
if (item.trustState === 'warning') {
return { status: 'blocked', reason: 'Skill market trust metadata contains warnings.' }