mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
fix: harden skill marketplace details
This commit is contained in:
parent
4f6508f495
commit
6ae97c020f
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)]"
|
||||
|
||||
136
desktop/src/components/skills/SkillList.test.tsx
Normal file
136
desktop/src/components/skills/SkillList.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
|
||||
@ -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': '閉じる',
|
||||
|
||||
@ -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': '닫기',
|
||||
|
||||
@ -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': '關閉',
|
||||
|
||||
@ -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': '关闭',
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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.' }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user