Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## [Unreleased]

### Added

- Native support for Capminal Skills (`Capminal/agent-skills`) in the Skills hub: browse, inspect, and install allowlisted Capminal skills with publisher branding.

## [2026.7.29] - 2026-07-29

### Added
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/assets/capminal-symbol.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
64 changes: 64 additions & 0 deletions frontend/src/views/skills/SkillsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { toast } from 'sonner'
import { SkillsPage } from './SkillsPage'
import bankrSymbolUrl from '@/assets/bankr-symbol.svg'
import capminalSymbolUrl from '@/assets/capminal-symbol.svg'
import robinhoodSymbolUrl from '@/assets/robinhood-symbol.png'

vi.mock('sonner', () => ({
Expand Down Expand Up @@ -140,6 +141,13 @@ const BANKR_CATALOG_ITEM = {
source: 'bankr',
description: 'Analyze token risk.',
}
const CAPMINAL_CATALOG_ITEM = {
name: 'morse-launch-b20',
identifier: 'https://github.com/Capminal/agent-skills/tree/main/morse-launch-b20',
provider: 'Capminal',
source: 'capminal',
description: 'Morse launch B20 skill.',
}
// A needs_setup skill carrying both a Requirements manifest and Missing bins/env
// (skills.js:743-777,792-803). requirements.items exercises the ready /
// needs_setup / missing_skill status branches plus the missing + requires detail.
Expand Down Expand Up @@ -336,6 +344,7 @@ describe('SkillsPage', () => {
expect(tabs.map((tab) => tab.getAttribute('aria-label'))).toEqual([
'Installed',
'Bankr',
'Capminal',
'Robinhood',
'Community',
])
Expand All @@ -344,6 +353,11 @@ describe('SkillsPage', () => {
.getByRole('presentation')
.getAttribute('src'),
).toBe(bankrSymbolUrl)
expect(
within(screen.getByRole('tab', { name: 'Capminal' }))
.getByRole('presentation')
.getAttribute('src'),
).toBe(capminalSymbolUrl)
expect(
within(screen.getByRole('tab', { name: 'Robinhood' }))
.getByRole('presentation')
Expand All @@ -363,6 +377,11 @@ describe('SkillsPage', () => {
const bankr = screen.getByRole('tab', { name: 'Bankr' })
expect(bankr).toHaveAttribute('aria-selected', 'true')
expect(bankr).toHaveFocus()

fireEvent.keyDown(bankr, { key: 'ArrowRight' })
const capminal = screen.getByRole('tab', { name: 'Capminal' })
expect(capminal).toHaveAttribute('aria-selected', 'true')
expect(capminal).toHaveFocus()
})

it('the status pill filters the installed list', async () => {
Expand Down Expand Up @@ -437,6 +456,16 @@ describe('SkillsPage', () => {
expect(within(card).getByRole('presentation')).toHaveAttribute('src', bankrSymbolUrl)
})

it('uses the official Capminal icon when a Capminal catalog item has no logo metadata', async () => {
wireRpc({ searchResults: [CAPMINAL_CATALOG_ITEM] })
renderPage()
await waitFor(() => expect(screen.getByLabelText('Skill trader')).toBeInTheDocument())
fireEvent.click(screen.getByRole('tab', { name: 'Capminal' }))

const card = await screen.findByLabelText('Catalog skill morse-launch-b20')
expect(within(card).getByRole('presentation')).toHaveAttribute('src', capminalSymbolUrl)
})

// ── Install (per-item busy, correct RPC + params + invalidation) ─────────
it('installing a catalog skill calls skills.install with identifier/source/force and reloads', async () => {
wireRpc()
Expand Down Expand Up @@ -912,4 +941,39 @@ describe('SkillsPage', () => {
renderPage()
await waitFor(() => expect(document.title).toBe('Skills - AgentOS Control'))
})

it('loads and searches Capminal skills when entering the Capminal tab', async () => {
wireRpc({
searchResults: [
{
name: 'capminal',
identifier: 'https://github.com/capminal-skills/capminal',
provider: 'Capminal',
source: 'capminal',
description: 'Cap World interaction',
category: 'crypto',
},
],
})
renderPage()
await waitFor(() => expect(screen.getByLabelText('Skill trader')).toBeInTheDocument())

fireEvent.click(screen.getByRole('tab', { name: 'Capminal' }))

const card = await screen.findByLabelText('Catalog skill capminal')
expect(card).toBeInTheDocument()
expect(within(card).getByText('Cap World interaction')).toBeInTheDocument()
expect(within(card).getByText('Crypto')).toBeInTheDocument()

fireEvent.click(within(card).getByRole('button', { name: 'View details for capminal' }))
const dialog = await screen.findByRole('dialog')
expect(within(dialog).getByText('Capminal')).toBeInTheDocument()
expect(within(dialog).getByText('Crypto')).toBeInTheDocument()

expect(mockRpc.call).toHaveBeenCalledWith('skills.search', {
query: '',
limit: 500,
source: 'capminal',
})
})
})
95 changes: 84 additions & 11 deletions frontend/src/views/skills/SkillsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { ModalShell } from '@/components/ModalShell'
import { Button } from '@/components/ui/button'
import { useRpc } from '@/app/providers'
import bankrSymbolUrl from '@/assets/bankr-symbol.svg'
import capminalSymbolUrl from '@/assets/capminal-symbol.svg'
import robinhoodSymbolUrl from '@/assets/robinhood-symbol.png'
import {
CAT_LABEL,
Expand Down Expand Up @@ -65,19 +66,25 @@ import {
// skills.js:7 — the Bankr partner tab is shown; the BankrSource backend stays
// wired either way so Bankr skills remain reachable via Community.
const SHOW_BANKR = true

type Tab = 'installed' | 'bankr' | 'robinhood' | 'community'
type RegistryGroup = 'bankr' | 'community'
type PartnerBrand = 'bankr' | 'robinhood'
const TAB_ORDER: Tab[] = SHOW_BANKR
? ['installed', 'bankr', 'robinhood', 'community']
: ['installed', 'robinhood', 'community']
const SHOW_CAPMINAL = true

type Tab = 'installed' | 'bankr' | 'capminal' | 'robinhood' | 'community'
type RegistryGroup = 'bankr' | 'capminal' | 'community'
type PartnerBrand = 'bankr' | 'capminal' | 'robinhood'
const TAB_ORDER: Tab[] = [
'installed',
...(SHOW_BANKR ? ['bankr' as const] : []),
...(SHOW_CAPMINAL ? ['capminal' as const] : []),
'robinhood',
'community',
]

// The bundled brand artwork stays a client-side asset: a local import is not
// something a SKILL.md could carry. Membership, however, is the payload's call —
// `publisher.id` is resolved against a server-side allowlist.
const PARTNER_BRANDS: Record<PartnerBrand, { label: string; asset: string }> = {
bankr: { label: 'Bankr', asset: bankrSymbolUrl },
capminal: { label: 'Capminal', asset: capminalSymbolUrl },
robinhood: { label: 'Robinhood', asset: robinhoodSymbolUrl },
}

Expand Down Expand Up @@ -184,6 +191,9 @@ function LogoBadge({ item, cls }: { item: RegistryItem; cls: string }) {
if (item.source?.toLowerCase() === 'bankr') {
return <PartnerLogo brand="bankr" className={cls} decorative />
}
if (item.source?.toLowerCase() === 'capminal') {
return <PartnerLogo brand="capminal" className={cls} decorative />
}
return <span className={`${cls} ${cls}--initials`}>{initials(item.provider || item.name)}</span>
}
return (
Expand Down Expand Up @@ -413,10 +423,12 @@ export function SkillsPage() {

// Registry (bankr/community) query text + debounced community query.
const [bankrQuery, setBankrQuery] = useState('')
const [capminalQuery, setCapminalQuery] = useState('')
const [robinhoodQuery, setRobinhoodQuery] = useState('')
const [communityText, setCommunityText] = useState('')
const [communityQuery, setCommunityQuery] = useState('')
const [bankrCat, setBankrCat] = useState('all')
const [capminalCat, setCapminalCat] = useState('all')
const [robinhoodStatus, setRobinhoodStatus] = useState<StatusFilter>('all')
const [communityCat, setCommunityCat] = useState('all')
const [githubUrl, setGithubUrl] = useState('')
Expand Down Expand Up @@ -482,14 +494,29 @@ export function SkillsPage() {
},
})

const capminalSnapshot = useQuery<RegistryItem[]>({
queryKey: ['skills.search', 'capminal'],
enabled: SHOW_CAPMINAL && tab === 'capminal',
refetchOnWindowFocus: false,
queryFn: async () => {
await rpc.waitForConnection()
const data = await rpc.call<SearchResponse>('skills.search', {
query: '',
limit: 500,
source: 'capminal',
})
return data.results ?? []
},
})

const communitySnapshot = useQuery<RegistryItem[]>({
queryKey: ['skills.search', 'community'],
enabled: tab === 'community',
refetchOnWindowFocus: false,
queryFn: async () => {
await rpc.waitForConnection()
const data = await rpc.call<SearchResponse>('skills.search', { query: '', limit: 500 })
return communityFilter(data.results ?? [], SHOW_BANKR)
return communityFilter(data.results ?? [], SHOW_BANKR, SHOW_CAPMINAL)
},
})

Expand All @@ -506,7 +533,7 @@ export function SkillsPage() {
query: communityQuery,
limit: 100,
})
return communityFilter(data.results ?? [], SHOW_BANKR)
return communityFilter(data.results ?? [], SHOW_BANKR, SHOW_CAPMINAL)
},
})

Expand Down Expand Up @@ -694,10 +721,22 @@ export function SkillsPage() {
[bankrSnapshot.data, sessionInstalls],
)

const capminalRows = useMemo(
() =>
mergeRegistryRows(
capminalSnapshot.data ?? [],
sessionInstalls.filter((r) => r.source === 'capminal'),
),
[capminalSnapshot.data, sessionInstalls],
)

const communityLive = communityQuery ? communitySearch.data : undefined
const communityBrowse = useMemo(
() =>
mergeRegistryRows(communitySnapshot.data ?? [], communityFilter(sessionInstalls, SHOW_BANKR)),
mergeRegistryRows(
communitySnapshot.data ?? [],
communityFilter(sessionInstalls, SHOW_BANKR, SHOW_CAPMINAL),
),
[communitySnapshot.data, sessionInstalls],
)
const communityRows = communityLive ?? communityBrowse
Expand All @@ -724,7 +763,11 @@ export function SkillsPage() {
*/
const registryItemFor = (d: Extract<Dialog, { kind: 'registry' }>): RegistryItem => {
const pools =
d.group === 'bankr' ? [bankrRows] : [communityRows, communityBrowse, communitySearch.data]
d.group === 'bankr'
? [bankrRows]
: d.group === 'capminal'
? [capminalRows]
: [communityRows, communityBrowse, communitySearch.data]
for (const pool of pools) {
const hit = (pool ?? []).find((r) => registryKey(r) === d.key)
if (hit) return hit
Expand All @@ -734,6 +777,7 @@ export function SkillsPage() {

const refresh = () => {
if (tab === 'bankr') void bankrSnapshot.refetch()
else if (tab === 'capminal') void capminalSnapshot.refetch()
else if (tab === 'community') {
void communitySnapshot.refetch()
if (communityQuery) void communitySearch.refetch()
Expand Down Expand Up @@ -778,6 +822,16 @@ export function SkillsPage() {
onSelect={setTab}
/>
) : null}
{SHOW_CAPMINAL ? (
<TabButton
current={tab}
tab="capminal"
label="Capminal"
description="Partner catalog"
icon={<PartnerLogo brand="capminal" className="sk-tab__brand" decorative />}
onSelect={setTab}
/>
) : null}
<TabButton
current={tab}
tab="robinhood"
Expand Down Expand Up @@ -874,6 +928,25 @@ export function SkillsPage() {
/>
) : null}

{SHOW_CAPMINAL && tab === 'capminal' ? (
<RegistryPanel
group="capminal"
snapshot={capminalSnapshot.data ?? []}
loading={capminalSnapshot.isLoading}
error={capminalSnapshot.isError ? String(capminalSnapshot.error) : ''}
query={capminalQuery}
onQuery={setCapminalQuery}
category={capminalCat}
onCategory={setCapminalCat}
forceArmed={forceArmed}
busyKeys={busyKeys}
onOpen={(item) =>
setDialog({ kind: 'registry', group: 'capminal', key: registryKey(item), item })
}
onInstall={runInstall}
/>
) : null}

{tab === 'robinhood' ? (
<RobinhoodPanel
skills={rhSkills}
Expand Down
17 changes: 12 additions & 5 deletions frontend/src/views/skills/logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,12 +328,18 @@ describe('partnerEmptyMessage', () => {
const item = (o: Partial<RegistryItem>): RegistryItem => o

describe('communityFilter', () => {
const rows = [item({ source: 'bankr', name: 'b' }), item({ source: 'clawhub', name: 'c' })]
it('drops bankr rows when the Bankr tab is shown', () => {
expect(communityFilter(rows, true).map((r) => r.name)).toEqual(['c'])
const rows = [
item({ source: 'bankr', name: 'b' }),
item({ source: 'capminal', name: 'cap' }),
item({ source: 'clawhub', name: 'c' }),
]
it('drops bankr and capminal rows when their tabs are shown', () => {
expect(communityFilter(rows, true, true).map((r) => r.name)).toEqual(['c'])
expect(communityFilter(rows, true, false).map((r) => r.name)).toEqual(['cap', 'c'])
expect(communityFilter(rows, false, true).map((r) => r.name)).toEqual(['b', 'c'])
})
it('keeps bankr rows when the Bankr tab is hidden', () => {
expect(communityFilter(rows, false).map((r) => r.name)).toEqual(['b', 'c'])
it('keeps bankr and capminal rows when their tabs are hidden', () => {
expect(communityFilter(rows, false, false).map((r) => r.name)).toEqual(['b', 'cap', 'c'])
})
})

Expand Down Expand Up @@ -439,6 +445,7 @@ describe('registryEmptyMessage / registryKey', () => {
it('query message takes precedence', () => {
expect(registryEmptyMessage('bankr', 'foo')).toContain('foo')
expect(registryEmptyMessage('bankr', '')).toContain('Bankr')
expect(registryEmptyMessage('capminal', '')).toContain('Capminal')
expect(registryEmptyMessage('community', '')).toContain('community')
})
it('registryKey prefers identifier then name', () => {
Expand Down
Loading