mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-19 13:33:35 +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 { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
|
||||||
import '@testing-library/jest-dom'
|
import '@testing-library/jest-dom'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import { Settings } from '../pages/Settings'
|
import { Settings } from '../pages/Settings'
|
||||||
import { useSkillStore } from '../stores/skillStore'
|
|
||||||
import { useSettingsStore } from '../stores/settingsStore'
|
import { useSettingsStore } from '../stores/settingsStore'
|
||||||
import { useSessionStore } from '../stores/sessionStore'
|
import { SKILL_CENTER_TAB_ID, useTabStore } from '../stores/tabStore'
|
||||||
import { useTabStore, SETTINGS_TAB_ID } from '../stores/tabStore'
|
|
||||||
import { useUIStore } from '../stores/uiStore'
|
import { useUIStore } from '../stores/uiStore'
|
||||||
|
|
||||||
vi.mock('../api/agents', () => ({
|
vi.mock('../api/agents', () => ({
|
||||||
@ -50,252 +48,42 @@ vi.mock('../stores/agentStore', () => ({
|
|||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../components/chat/CodeViewer', () => ({
|
describe('Settings > Skills compatibility entry', () => {
|
||||||
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', () => {
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
useSettingsStore.setState({ locale: 'en' })
|
useSettingsStore.setState({ locale: 'en' })
|
||||||
useSessionStore.setState({
|
useTabStore.setState(useTabStore.getInitialState(), true)
|
||||||
sessions: [
|
useUIStore.setState({
|
||||||
{
|
activeSettingsTab: 'providers',
|
||||||
id: 'session-1',
|
pendingSettingsTab: null,
|
||||||
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,
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders browser summary and grouped skill cards', () => {
|
it('opens the unified Skill Center from the legacy settings tab button', () => {
|
||||||
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(<Settings />)
|
render(<Settings />)
|
||||||
switchToSkillsTab()
|
|
||||||
|
|
||||||
expect(screen.getByText('Browse installed skills')).toBeInTheDocument()
|
fireEvent.click(screen.getByText('Skills'))
|
||||||
expect(screen.getByText('Skill Browser')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('Total skills')).toBeInTheDocument()
|
expect(useTabStore.getState().activeTabId).toBe(SKILL_CENTER_TAB_ID)
|
||||||
expect(screen.getByText('Alpha Skill')).toBeInTheDocument()
|
expect(useTabStore.getState().tabs).toContainEqual({
|
||||||
expect(screen.getByText('Second skill description')).toBeInTheDocument()
|
sessionId: SKILL_CENTER_TAB_ID,
|
||||||
expect(screen.getAllByText('Plugin').length).toBeGreaterThan(0)
|
title: 'Skills',
|
||||||
expect(screen.getByText('Telegram Access')).toBeInTheDocument()
|
type: 'skill-center',
|
||||||
|
status: 'idle',
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('filters installed skills locally by keyword and clears the search', () => {
|
it('redirects pending legacy skills settings navigation to the Skill Center', async () => {
|
||||||
useSkillStore.setState({
|
useUIStore.setState({
|
||||||
skills: [
|
activeSettingsTab: 'providers',
|
||||||
{
|
pendingSettingsTab: '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(<Settings />)
|
render(<Settings />)
|
||||||
switchToSkillsTab()
|
|
||||||
|
|
||||||
const searchInput = screen.getByPlaceholderText('Search skills by name, description, or source...')
|
await waitFor(() => {
|
||||||
fireEvent.change(searchInput, { target: { value: 'telegram' } })
|
expect(useTabStore.getState().activeTabId).toBe(SKILL_CENTER_TAB_ID)
|
||||||
|
|
||||||
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,
|
|
||||||
})
|
})
|
||||||
useTabStore.setState({
|
expect(useUIStore.getState().pendingSettingsTab).toBeNull()
|
||||||
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()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -2,10 +2,13 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import '@testing-library/jest-dom'
|
import '@testing-library/jest-dom'
|
||||||
|
|
||||||
const { sessionsApiMock } = vi.hoisted(() => ({
|
const { sessionsApiMock, skillsApiMock } = vi.hoisted(() => ({
|
||||||
sessionsApiMock: {
|
sessionsApiMock: {
|
||||||
getInspection: vi.fn(),
|
getInspection: vi.fn(),
|
||||||
},
|
},
|
||||||
|
skillsApiMock: {
|
||||||
|
list: vi.fn(),
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../../api/sessions', async (importOriginal) => {
|
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 { LocalSlashCommandPanel } from './LocalSlashCommandPanel'
|
||||||
import { useSettingsStore } from '../../stores/settingsStore'
|
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 { useUIStore } from '../../stores/uiStore'
|
||||||
|
import { useSkillStore } from '../../stores/skillStore'
|
||||||
import type { SessionContextSnapshot, SessionInspectionResponse } from '../../api/sessions'
|
import type { SessionContextSnapshot, SessionInspectionResponse } from '../../api/sessions'
|
||||||
|
|
||||||
const baseContext: SessionContextSnapshot = {
|
const baseContext: SessionContextSnapshot = {
|
||||||
@ -76,6 +86,17 @@ describe('LocalSlashCommandPanel memory context', () => {
|
|||||||
pendingMemoryPath: null,
|
pendingMemoryPath: null,
|
||||||
pendingSettingsTab: 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 () => {
|
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)
|
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'
|
} from '../../api/sessions'
|
||||||
import { useTranslation, type TranslationKey } from '../../i18n'
|
import { useTranslation, type TranslationKey } from '../../i18n'
|
||||||
import { useUIStore } from '../../stores/uiStore'
|
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 { useMcpStore } from '../../stores/mcpStore'
|
||||||
import { useSkillStore } from '../../stores/skillStore'
|
import { useSkillStore } from '../../stores/skillStore'
|
||||||
import type { McpServerRecord } from '../../types/mcp'
|
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 }) {
|
function SkillsPanel({ cwd, onClose }: { cwd?: string; onClose: () => void }) {
|
||||||
const t = useTranslation()
|
const t = useTranslation()
|
||||||
const setPendingSettingsTab = useUIStore((s) => s.setPendingSettingsTab)
|
|
||||||
const fetchSkillDetail = useSkillStore((s) => s.fetchSkillDetail)
|
const fetchSkillDetail = useSkillStore((s) => s.fetchSkillDetail)
|
||||||
const [skills, setSkills] = useState<SkillMeta[] | null>(null)
|
const [skills, setSkills] = useState<SkillMeta[] | null>(null)
|
||||||
const [error, setError] = useState<string | 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}`}
|
key={`${skill.source}:${skill.name}`}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await fetchSkillDetail(skill.source, skill.name, cwd, 'skills')
|
await fetchSkillDetail(skill.source, skill.name, cwd, 'skills')
|
||||||
setPendingSettingsTab('skills')
|
useTabStore.getState().openTab(SKILL_CENTER_TAB_ID, t('skillCenter.title'), 'skill-center')
|
||||||
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
|
|
||||||
onClose()
|
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)]"
|
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
|
||||||
'settings.skills.title': 'Installed Skills',
|
'settings.skills.title': 'Installed Skills',
|
||||||
'settings.skills.description': 'Skills extend Claude with specialized capabilities. Manage skills in ~/.claude/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.browserTitle': 'Browse installed skills',
|
||||||
'settings.skills.browserEyebrow': 'Skill Browser',
|
'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.',
|
'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.blockedAction': 'Blocked',
|
||||||
'skillCenter.marketplace.openSource': 'Open source',
|
'skillCenter.marketplace.openSource': 'Open source',
|
||||||
'skillCenter.marketplace.openUpstream': 'Open upstream',
|
'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 ──────────────────────────────────────
|
||||||
'tabs.close': 'Close',
|
'tabs.close': 'Close',
|
||||||
|
|||||||
@ -715,6 +715,8 @@ export const jp: Record<TranslationKey, string> = {
|
|||||||
// Settings > Skills
|
// Settings > Skills
|
||||||
'settings.skills.title': 'インストール済みスキル',
|
'settings.skills.title': 'インストール済みスキル',
|
||||||
'settings.skills.description': 'スキルは Claude に専門的な機能を追加します。スキルは ~/.claude/skills/ で管理します',
|
'settings.skills.description': 'スキルは Claude に専門的な機能を追加します。スキルは ~/.claude/skills/ で管理します',
|
||||||
|
'settings.skills.redirectDescription': 'スキルは統合されたスキルセンターで管理されます。マーケットとインストール済みスキルを同じ場所で扱えます。',
|
||||||
|
'settings.skills.openSkillCenter': 'スキルセンターを開く',
|
||||||
'settings.skills.browserTitle': 'インストール済みスキルを閲覧',
|
'settings.skills.browserTitle': 'インストール済みスキルを閲覧',
|
||||||
'settings.skills.browserEyebrow': 'スキルブラウザ',
|
'settings.skills.browserEyebrow': 'スキルブラウザ',
|
||||||
'settings.skills.browserDescription': 'バンドル、プロジェクト、ユーザーのスキルを確認し、スコープを比較し、各スキルフォルダを開いてドキュメントやソースファイルを読みます。',
|
'settings.skills.browserDescription': 'バンドル、プロジェクト、ユーザーのスキルを確認し、スコープを比較し、各スキルフォルダを開いてドキュメントやソースファイルを読みます。',
|
||||||
@ -2055,6 +2057,12 @@ export const jp: Record<TranslationKey, string> = {
|
|||||||
'skillCenter.marketplace.blockedAction': 'インストール不可',
|
'skillCenter.marketplace.blockedAction': 'インストール不可',
|
||||||
'skillCenter.marketplace.openSource': 'ソースを開く',
|
'skillCenter.marketplace.openSource': 'ソースを開く',
|
||||||
'skillCenter.marketplace.openUpstream': '上流を開く',
|
'skillCenter.marketplace.openUpstream': '上流を開く',
|
||||||
|
'skillCenter.marketplace.confirmTitle': 'スキルのインストールを確認',
|
||||||
|
'skillCenter.marketplace.confirmBody': 'インストール前にソースと対象ディレクトリを確認してください。スキルはローカルのユーザースキルディレクトリに書き込まれます。',
|
||||||
|
'skillCenter.marketplace.confirmSkill': 'スキル',
|
||||||
|
'skillCenter.marketplace.confirmTarget': '対象',
|
||||||
|
'skillCenter.marketplace.confirmInstall': 'スキルをインストール',
|
||||||
|
'skillCenter.marketplace.noRiskLabels': 'リスクシグナルなし',
|
||||||
|
|
||||||
// ─── Tabs ──────────────────────────────────────
|
// ─── Tabs ──────────────────────────────────────
|
||||||
'tabs.close': '閉じる',
|
'tabs.close': '閉じる',
|
||||||
|
|||||||
@ -715,6 +715,8 @@ export const kr: Record<TranslationKey, string> = {
|
|||||||
// Settings > Skills
|
// Settings > Skills
|
||||||
'settings.skills.title': '설치된 스킬',
|
'settings.skills.title': '설치된 스킬',
|
||||||
'settings.skills.description': '스킬은 Claude에 전문 기능을 추가합니다. 스킬은 ~/.claude/skills/에서 관리합니다',
|
'settings.skills.description': '스킬은 Claude에 전문 기능을 추가합니다. 스킬은 ~/.claude/skills/에서 관리합니다',
|
||||||
|
'settings.skills.redirectDescription': '스킬은 이제 통합 스킬 센터에서 관리합니다. 마켓과 설치된 스킬을 한 곳에서 볼 수 있습니다.',
|
||||||
|
'settings.skills.openSkillCenter': '스킬 센터 열기',
|
||||||
'settings.skills.browserTitle': '설치된 스킬 살펴보기',
|
'settings.skills.browserTitle': '설치된 스킬 살펴보기',
|
||||||
'settings.skills.browserEyebrow': '스킬 브라우저',
|
'settings.skills.browserEyebrow': '스킬 브라우저',
|
||||||
'settings.skills.browserDescription': '번들, 프로젝트, 사용자 스킬을 확인하고 범위를 비교하며, 각 스킬 폴더를 열어 문서와 소스 파일을 읽습니다.',
|
'settings.skills.browserDescription': '번들, 프로젝트, 사용자 스킬을 확인하고 범위를 비교하며, 각 스킬 폴더를 열어 문서와 소스 파일을 읽습니다.',
|
||||||
@ -2055,6 +2057,12 @@ export const kr: Record<TranslationKey, string> = {
|
|||||||
'skillCenter.marketplace.blockedAction': '설치 불가',
|
'skillCenter.marketplace.blockedAction': '설치 불가',
|
||||||
'skillCenter.marketplace.openSource': '소스 열기',
|
'skillCenter.marketplace.openSource': '소스 열기',
|
||||||
'skillCenter.marketplace.openUpstream': '업스트림 열기',
|
'skillCenter.marketplace.openUpstream': '업스트림 열기',
|
||||||
|
'skillCenter.marketplace.confirmTitle': '스킬 설치 확인',
|
||||||
|
'skillCenter.marketplace.confirmBody': '설치 전에 소스와 대상 디렉터리를 확인하세요. 스킬은 로컬 사용자 스킬 디렉터리에 기록됩니다.',
|
||||||
|
'skillCenter.marketplace.confirmSkill': '스킬',
|
||||||
|
'skillCenter.marketplace.confirmTarget': '대상',
|
||||||
|
'skillCenter.marketplace.confirmInstall': '스킬 설치',
|
||||||
|
'skillCenter.marketplace.noRiskLabels': '보고된 위험 신호 없음',
|
||||||
|
|
||||||
// ─── Tabs ──────────────────────────────────────
|
// ─── Tabs ──────────────────────────────────────
|
||||||
'tabs.close': '닫기',
|
'tabs.close': '닫기',
|
||||||
|
|||||||
@ -715,6 +715,8 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
// Settings > Skills
|
// Settings > Skills
|
||||||
'settings.skills.title': '已安裝技能',
|
'settings.skills.title': '已安裝技能',
|
||||||
'settings.skills.description': '技能擴充套件 Claude 的能力。在 ~/.claude/skills/ 中管理技能。',
|
'settings.skills.description': '技能擴充套件 Claude 的能力。在 ~/.claude/skills/ 中管理技能。',
|
||||||
|
'settings.skills.redirectDescription': '技能現在統一放在技能中心管理,市場和已安裝技能會放在同一個入口裡。',
|
||||||
|
'settings.skills.openSkillCenter': '開啟技能中心',
|
||||||
'settings.skills.browserTitle': '瀏覽已安裝技能',
|
'settings.skills.browserTitle': '瀏覽已安裝技能',
|
||||||
'settings.skills.browserEyebrow': '技能瀏覽器',
|
'settings.skills.browserEyebrow': '技能瀏覽器',
|
||||||
'settings.skills.browserDescription': '檢視內建、專案和使用者技能,比較它們的來源與規模,並開啟技能目錄閱讀文件和原始碼檔案。',
|
'settings.skills.browserDescription': '檢視內建、專案和使用者技能,比較它們的來源與規模,並開啟技能目錄閱讀文件和原始碼檔案。',
|
||||||
@ -2055,6 +2057,12 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'skillCenter.marketplace.blockedAction': '不可安裝',
|
'skillCenter.marketplace.blockedAction': '不可安裝',
|
||||||
'skillCenter.marketplace.openSource': '開啟來源',
|
'skillCenter.marketplace.openSource': '開啟來源',
|
||||||
'skillCenter.marketplace.openUpstream': '開啟上游',
|
'skillCenter.marketplace.openUpstream': '開啟上游',
|
||||||
|
'skillCenter.marketplace.confirmTitle': '確認安裝技能',
|
||||||
|
'skillCenter.marketplace.confirmBody': '安裝前請確認來源和目標目錄。技能會寫入本機使用者技能目錄。',
|
||||||
|
'skillCenter.marketplace.confirmSkill': '技能',
|
||||||
|
'skillCenter.marketplace.confirmTarget': '目標',
|
||||||
|
'skillCenter.marketplace.confirmInstall': '安裝技能',
|
||||||
|
'skillCenter.marketplace.noRiskLabels': '未回報風險訊號',
|
||||||
|
|
||||||
// ─── Tabs ──────────────────────────────────────
|
// ─── Tabs ──────────────────────────────────────
|
||||||
'tabs.close': '關閉',
|
'tabs.close': '關閉',
|
||||||
|
|||||||
@ -715,6 +715,8 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
// Settings > Skills
|
// Settings > Skills
|
||||||
'settings.skills.title': '已安装技能',
|
'settings.skills.title': '已安装技能',
|
||||||
'settings.skills.description': '技能扩展 Claude 的能力。在 ~/.claude/skills/ 中管理技能。',
|
'settings.skills.description': '技能扩展 Claude 的能力。在 ~/.claude/skills/ 中管理技能。',
|
||||||
|
'settings.skills.redirectDescription': '技能现在统一放在技能中心管理,市场和已安装技能会放在同一个入口里。',
|
||||||
|
'settings.skills.openSkillCenter': '打开技能中心',
|
||||||
'settings.skills.browserTitle': '浏览已安装技能',
|
'settings.skills.browserTitle': '浏览已安装技能',
|
||||||
'settings.skills.browserEyebrow': '技能浏览器',
|
'settings.skills.browserEyebrow': '技能浏览器',
|
||||||
'settings.skills.browserDescription': '查看内置、项目和用户技能,比较它们的来源与规模,并打开技能目录阅读文档和源码文件。',
|
'settings.skills.browserDescription': '查看内置、项目和用户技能,比较它们的来源与规模,并打开技能目录阅读文档和源码文件。',
|
||||||
@ -2055,6 +2057,12 @@ export const zh: Record<TranslationKey, string> = {
|
|||||||
'skillCenter.marketplace.blockedAction': '不可安装',
|
'skillCenter.marketplace.blockedAction': '不可安装',
|
||||||
'skillCenter.marketplace.openSource': '打开来源',
|
'skillCenter.marketplace.openSource': '打开来源',
|
||||||
'skillCenter.marketplace.openUpstream': '打开上游',
|
'skillCenter.marketplace.openUpstream': '打开上游',
|
||||||
|
'skillCenter.marketplace.confirmTitle': '确认安装技能',
|
||||||
|
'skillCenter.marketplace.confirmBody': '安装前请确认来源和目标目录。技能会写入本机用户技能目录。',
|
||||||
|
'skillCenter.marketplace.confirmSkill': '技能',
|
||||||
|
'skillCenter.marketplace.confirmTarget': '目标',
|
||||||
|
'skillCenter.marketplace.confirmInstall': '安装技能',
|
||||||
|
'skillCenter.marketplace.noRiskLabels': '未报告风险信号',
|
||||||
|
|
||||||
// ─── Tabs ──────────────────────────────────────
|
// ─── Tabs ──────────────────────────────────────
|
||||||
'tabs.close': '关闭',
|
'tabs.close': '关闭',
|
||||||
|
|||||||
@ -36,9 +36,6 @@ import { useAgentStore } from '../stores/agentStore'
|
|||||||
import { useSessionStore } from '../stores/sessionStore'
|
import { useSessionStore } from '../stores/sessionStore'
|
||||||
import type { AgentDefinition, AgentSource } from '../api/agents'
|
import type { AgentDefinition, AgentSource } from '../api/agents'
|
||||||
import { MarkdownRenderer } from '../components/markdown/MarkdownRenderer'
|
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 { usePluginStore } from '../stores/pluginStore'
|
||||||
import { PluginList } from '../components/plugins/PluginList'
|
import { PluginList } from '../components/plugins/PluginList'
|
||||||
import { PluginDetail } from '../components/plugins/PluginDetail'
|
import { PluginDetail } from '../components/plugins/PluginDetail'
|
||||||
@ -78,6 +75,7 @@ import {
|
|||||||
stripProviderSettingsJsonEnv,
|
stripProviderSettingsJsonEnv,
|
||||||
} from '../lib/providerSettingsJson'
|
} from '../lib/providerSettingsJson'
|
||||||
import { copyTextToClipboard } from '../components/chat/clipboard'
|
import { copyTextToClipboard } from '../components/chat/clipboard'
|
||||||
|
import { SKILL_CENTER_TAB_ID, useTabStore } from '../stores/tabStore'
|
||||||
|
|
||||||
const NETWORK_TIMEOUT_MIN_SECONDS = 30
|
const NETWORK_TIMEOUT_MIN_SECONDS = 30
|
||||||
const NETWORK_TIMEOUT_MAX_SECONDS = 1800
|
const NETWORK_TIMEOUT_MAX_SECONDS = 1800
|
||||||
@ -207,6 +205,11 @@ export function Settings() {
|
|||||||
useUIStore.getState().setPendingSettingsTab(null)
|
useUIStore.getState().setPendingSettingsTab(null)
|
||||||
}, [pendingSettingsTab, setActiveTab])
|
}, [pendingSettingsTab, setActiveTab])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab !== 'skills') return
|
||||||
|
useTabStore.getState().openTab(SKILL_CENTER_TAB_ID, t('skillCenter.title'), 'skill-center')
|
||||||
|
}, [activeTab, t])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col overflow-hidden bg-[var(--color-surface)]">
|
<div className="flex-1 flex flex-col overflow-hidden bg-[var(--color-surface)]">
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<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="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="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="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="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="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')} />
|
<TabButton icon="mouse" label={t('settings.tab.computerUse')} active={activeTab === 'computerUse'} onClick={() => setActiveTab('computerUse')} />
|
||||||
@ -4321,26 +4329,24 @@ function DetailStat({
|
|||||||
// ─── Skill Settings ──────────────────────────────────────
|
// ─── Skill Settings ──────────────────────────────────────
|
||||||
|
|
||||||
function SkillSettings() {
|
function SkillSettings() {
|
||||||
const selectedSkill = useSkillStore((s) => s.selectedSkill)
|
|
||||||
const t = useTranslation()
|
const t = useTranslation()
|
||||||
|
|
||||||
if (selectedSkill) {
|
|
||||||
return (
|
|
||||||
<div className="w-full min-w-0">
|
|
||||||
<SkillDetail />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
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">
|
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">
|
||||||
{t('settings.skills.title')}
|
{t('settings.skills.title')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-[var(--color-text-tertiary)] mb-4">
|
<p className="text-sm text-[var(--color-text-tertiary)] mb-4">
|
||||||
{t('settings.skills.description')}
|
{t('settings.skills.redirectDescription')}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
import { skillMarketApi } from '../api/skillMarket'
|
import { skillMarketApi } from '../api/skillMarket'
|
||||||
import { useSettingsStore } from '../stores/settingsStore'
|
import { useSettingsStore } from '../stores/settingsStore'
|
||||||
import { useSkillMarketStore } from '../stores/skillMarketStore'
|
import { useSkillMarketStore } from '../stores/skillMarketStore'
|
||||||
|
import { useSkillStore } from '../stores/skillStore'
|
||||||
import type { SkillMarketDetail, SkillMarketItem } from '../types/skillMarket'
|
import type { SkillMarketDetail, SkillMarketItem } from '../types/skillMarket'
|
||||||
import { SkillCenter } from './SkillCenter'
|
import { SkillCenter } from './SkillCenter'
|
||||||
|
|
||||||
@ -19,6 +20,16 @@ vi.mock('../components/skills/SkillList', () => ({
|
|||||||
SkillList: () => <div data-testid="installed-skill-list">Installed skills</div>,
|
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)
|
const mockedSkillMarketApi = vi.mocked(skillMarketApi)
|
||||||
|
|
||||||
function makeItem(overrides: Partial<SkillMarketItem> = {}): SkillMarketItem {
|
function makeItem(overrides: Partial<SkillMarketItem> = {}): SkillMarketItem {
|
||||||
@ -66,6 +77,17 @@ describe('SkillCenter', () => {
|
|||||||
isInstalling: false,
|
isInstalling: false,
|
||||||
error: null,
|
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({
|
mockedSkillMarketApi.list.mockResolvedValue({
|
||||||
items: [makeItem()],
|
items: [makeItem()],
|
||||||
nextCursor: null,
|
nextCursor: null,
|
||||||
@ -95,6 +117,14 @@ describe('SkillCenter', () => {
|
|||||||
isInstalling: false,
|
isInstalling: false,
|
||||||
error: null,
|
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 () => {
|
it('loads marketplace cards and opens a detail confirmation panel', async () => {
|
||||||
@ -116,6 +146,16 @@ describe('SkillCenter', () => {
|
|||||||
expect(detail).toBeInTheDocument()
|
expect(detail).toBeInTheDocument()
|
||||||
expect(screen.getByRole('button', { name: 'Install' })).toBeEnabled()
|
expect(screen.getByRole('button', { name: 'Install' })).toBeEnabled()
|
||||||
expect(screen.getByText('Open upstream')).toHaveAttribute('href', 'https://github.com/example/ppt-generator')
|
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 () => {
|
it('submits market searches without auto-searching every keystroke', async () => {
|
||||||
@ -166,4 +206,37 @@ describe('SkillCenter', () => {
|
|||||||
expect(screen.getByTestId('installed-skill-list')).toBeInTheDocument()
|
expect(screen.getByTestId('installed-skill-list')).toBeInTheDocument()
|
||||||
expect(within(screen.getByTestId('skill-mine-tab')).getByText('Installed skills')).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,
|
X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { SkillList } from '../components/skills/SkillList'
|
import { SkillList } from '../components/skills/SkillList'
|
||||||
|
import { SkillDetail } from '../components/skills/SkillDetail'
|
||||||
|
import { ConfirmDialog } from '../components/shared/ConfirmDialog'
|
||||||
import { useTranslation } from '../i18n'
|
import { useTranslation } from '../i18n'
|
||||||
import { useSkillMarketStore } from '../stores/skillMarketStore'
|
import { useSkillMarketStore } from '../stores/skillMarketStore'
|
||||||
|
import { useSkillStore } from '../stores/skillStore'
|
||||||
import type {
|
import type {
|
||||||
SkillMarketDetail,
|
SkillMarketDetail,
|
||||||
SkillMarketInstallEligibility,
|
SkillMarketInstallEligibility,
|
||||||
@ -31,7 +34,11 @@ const TRUST_SAFE: SkillMarketTrustState[] = ['clean', 'benign', 'signed', 'offic
|
|||||||
|
|
||||||
export function SkillCenter() {
|
export function SkillCenter() {
|
||||||
const t = useTranslation()
|
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 {
|
const {
|
||||||
items,
|
items,
|
||||||
selectedDetail,
|
selectedDetail,
|
||||||
@ -52,8 +59,15 @@ export function SkillCenter() {
|
|||||||
} = useSkillMarketStore()
|
} = useSkillMarketStore()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (activeTab !== 'marketplace') return
|
||||||
void fetchItems()
|
void fetchItems()
|
||||||
}, [fetchItems, source, sort])
|
}, [activeTab, fetchItems, source, sort])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedInstalledSkill) {
|
||||||
|
setActiveTab('mine')
|
||||||
|
}
|
||||||
|
}, [selectedInstalledSkill])
|
||||||
|
|
||||||
const installedCount = useMemo(
|
const installedCount = useMemo(
|
||||||
() => items.filter((item) => item.installed).length,
|
() => items.filter((item) => item.installed).length,
|
||||||
@ -215,14 +229,28 @@ export function SkillCenter() {
|
|||||||
detail={selectedDetail}
|
detail={selectedDetail}
|
||||||
loading={isDetailLoading}
|
loading={isDetailLoading}
|
||||||
installing={isInstalling}
|
installing={isInstalling}
|
||||||
onInstall={() => void installSelected()}
|
onInstall={() => selectedDetail && setPendingInstallDetail(selectedDetail)}
|
||||||
onClose={clearDetail}
|
onClose={clearDetail}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
) : (
|
) : (
|
||||||
<div role="tabpanel" data-testid="skill-mine-tab">
|
<div role="tabpanel" data-testid="skill-mine-tab">
|
||||||
<SkillList />
|
{selectedInstalledSkill ? <SkillDetail /> : <SkillList />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</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({
|
function SkillMarketCard({
|
||||||
item,
|
item,
|
||||||
active,
|
active,
|
||||||
|
|||||||
@ -90,13 +90,19 @@ describe('skillMarketStore', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('updates source, sort, and query filters', () => {
|
it('updates source, sort, and query filters', () => {
|
||||||
|
useSkillMarketStore.setState({ selectedDetail: makeDetail() })
|
||||||
|
|
||||||
useSkillMarketStore.getState().setSource('clawhub')
|
useSkillMarketStore.getState().setSource('clawhub')
|
||||||
|
expect(useSkillMarketStore.getState().selectedDetail).toBeNull()
|
||||||
|
|
||||||
|
useSkillMarketStore.setState({ selectedDetail: makeDetail() })
|
||||||
useSkillMarketStore.getState().setSort('updated')
|
useSkillMarketStore.getState().setSort('updated')
|
||||||
useSkillMarketStore.getState().setQuery('security')
|
useSkillMarketStore.getState().setQuery('security')
|
||||||
|
|
||||||
expect(useSkillMarketStore.getState().source).toBe('clawhub')
|
expect(useSkillMarketStore.getState().source).toBe('clawhub')
|
||||||
expect(useSkillMarketStore.getState().sort).toBe('updated')
|
expect(useSkillMarketStore.getState().sort).toBe('updated')
|
||||||
expect(useSkillMarketStore.getState().query).toBe('security')
|
expect(useSkillMarketStore.getState().query).toBe('security')
|
||||||
|
expect(useSkillMarketStore.getState().selectedDetail).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('loads selected marketplace detail', async () => {
|
it('loads selected marketplace detail', async () => {
|
||||||
@ -111,6 +117,31 @@ describe('skillMarketStore', () => {
|
|||||||
expect(useSkillMarketStore.getState().error).toBeNull()
|
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 () => {
|
it('installs selected detail and refreshes the list and detail', async () => {
|
||||||
const selected = makeDetail({ version: '1.0.0' })
|
const selected = makeDetail({ version: '1.0.0' })
|
||||||
const refreshed = makeDetail({
|
const refreshed = makeDetail({
|
||||||
|
|||||||
@ -27,6 +27,8 @@ type SkillMarketStore = {
|
|||||||
clearDetail: () => void
|
clearDetail: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let detailRequestSeq = 0
|
||||||
|
|
||||||
export const useSkillMarketStore = create<SkillMarketStore>((set, get) => ({
|
export const useSkillMarketStore = create<SkillMarketStore>((set, get) => ({
|
||||||
items: [],
|
items: [],
|
||||||
selectedDetail: null,
|
selectedDetail: null,
|
||||||
@ -38,13 +40,20 @@ export const useSkillMarketStore = create<SkillMarketStore>((set, get) => ({
|
|||||||
isInstalling: false,
|
isInstalling: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
setSource: (source) => set({ source }),
|
setSource: (source) => {
|
||||||
setSort: (sort) => set({ sort }),
|
detailRequestSeq += 1
|
||||||
|
set({ source, selectedDetail: null, isDetailLoading: false })
|
||||||
|
},
|
||||||
|
setSort: (sort) => {
|
||||||
|
detailRequestSeq += 1
|
||||||
|
set({ sort, selectedDetail: null, isDetailLoading: false })
|
||||||
|
},
|
||||||
setQuery: (query) => set({ query }),
|
setQuery: (query) => set({ query }),
|
||||||
|
|
||||||
fetchItems: async () => {
|
fetchItems: async () => {
|
||||||
const { source, sort, query } = get()
|
const { source, sort, query } = get()
|
||||||
set({ isLoading: true, error: null })
|
detailRequestSeq += 1
|
||||||
|
set({ isLoading: true, isDetailLoading: false, selectedDetail: null, error: null })
|
||||||
try {
|
try {
|
||||||
const result = await skillMarketApi.list({
|
const result = await skillMarketApi.list({
|
||||||
source,
|
source,
|
||||||
@ -61,11 +70,15 @@ export const useSkillMarketStore = create<SkillMarketStore>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
fetchDetail: async (source, slug) => {
|
fetchDetail: async (source, slug) => {
|
||||||
set({ isDetailLoading: true, error: null })
|
const requestId = detailRequestSeq + 1
|
||||||
|
detailRequestSeq = requestId
|
||||||
|
set({ isDetailLoading: true, selectedDetail: null, error: null })
|
||||||
try {
|
try {
|
||||||
const { detail } = await skillMarketApi.detail(source, slug)
|
const { detail } = await skillMarketApi.detail(source, slug)
|
||||||
|
if (requestId !== detailRequestSeq) return
|
||||||
set({ selectedDetail: detail, isDetailLoading: false })
|
set({ selectedDetail: detail, isDetailLoading: false })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (requestId !== detailRequestSeq) return
|
||||||
set({
|
set({
|
||||||
isDetailLoading: false,
|
isDetailLoading: false,
|
||||||
error: getErrorMessage(err),
|
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 {
|
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 () => {
|
it('blocks detail installs when list trust metadata is not installable', async () => {
|
||||||
const service = createSkillMarketService({
|
const service = createSkillMarketService({
|
||||||
fetchImpl: async () => Response.json(SKILLHUB_TOP_SKILLS_RESPONSE),
|
fetchImpl: async () => Response.json(SKILLHUB_TOP_SKILLS_RESPONSE),
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import type {
|
|||||||
SkillMarketItem,
|
SkillMarketItem,
|
||||||
SkillMarketListResult,
|
SkillMarketListResult,
|
||||||
SkillMarketSource,
|
SkillMarketSource,
|
||||||
SkillMarketTrustState,
|
|
||||||
} from './types.js'
|
} from './types.js'
|
||||||
|
|
||||||
export type SkillMarketListSource = 'auto' | 'clawhub' | 'skillhub'
|
export type SkillMarketListSource = 'auto' | 'clawhub' | 'skillhub'
|
||||||
@ -44,13 +43,6 @@ const DEFAULT_LIMIT = 24
|
|||||||
const MAX_LIMIT = 100
|
const MAX_LIMIT = 100
|
||||||
const CATALOG_CACHE_TTL_MS = 5 * 60 * 1_000
|
const CATALOG_CACHE_TTL_MS = 5 * 60 * 1_000
|
||||||
const FAILURE_CACHE_TTL_MS = 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 = {
|
type CatalogCacheEntry = {
|
||||||
expiresAt: number
|
expiresAt: number
|
||||||
result: SkillMarketListResult
|
result: SkillMarketListResult
|
||||||
@ -242,8 +234,13 @@ function installEligibilityFromListItem(item: SkillMarketItem): SkillMarketDetai
|
|||||||
installedSkillName: item.slug,
|
installedSkillName: item.slug,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (DETAIL_INSTALLABLE_TRUST_STATES.has(item.trustState)) {
|
if (
|
||||||
return { status: 'installable' }
|
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') {
|
if (item.trustState === 'warning') {
|
||||||
return { status: 'blocked', reason: 'Skill market trust metadata contains warnings.' }
|
return { status: 'blocked', reason: 'Skill market trust metadata contains warnings.' }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user