From 8e273de8df1409ef72490a9a53357faa048004f4 Mon Sep 17 00:00:00 2001 From: Bukee Date: Mon, 27 Jul 2026 11:31:57 -0700 Subject: [PATCH 1/7] feat(skills): add native support for Capminal Skills (partner source + tab) --- frontend/src/assets/capminal-symbol.svg | 7 + frontend/src/views/skills/SkillsPage.test.tsx | 47 ++++ frontend/src/views/skills/SkillsPage.tsx | 78 ++++++- frontend/src/views/skills/logic.test.ts | 17 +- frontend/src/views/skills/logic.ts | 27 ++- src/agentos/skills/hub/capminal.py | 211 ++++++++++++++++++ src/agentos/skills/hub/defaults.py | 2 + tests/test_skills_hub_capminal.py | 173 ++++++++++++++ 8 files changed, 539 insertions(+), 23 deletions(-) create mode 100644 frontend/src/assets/capminal-symbol.svg create mode 100644 src/agentos/skills/hub/capminal.py create mode 100644 tests/test_skills_hub_capminal.py diff --git a/frontend/src/assets/capminal-symbol.svg b/frontend/src/assets/capminal-symbol.svg new file mode 100644 index 000000000..d5adbf8c1 --- /dev/null +++ b/frontend/src/assets/capminal-symbol.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/src/views/skills/SkillsPage.test.tsx b/frontend/src/views/skills/SkillsPage.test.tsx index 211759668..01f4ea35c 100644 --- a/frontend/src/views/skills/SkillsPage.test.tsx +++ b/frontend/src/views/skills/SkillsPage.test.tsx @@ -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', () => ({ @@ -215,6 +216,7 @@ describe('SkillsPage', () => { expect(tabs.map((tab) => tab.getAttribute('aria-label'))).toEqual([ 'Installed', 'Bankr', + 'Capminal', 'Robinhood', 'Community', ]) @@ -223,6 +225,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') @@ -242,6 +249,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 () => { @@ -612,4 +624,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', + }) + }) }) diff --git a/frontend/src/views/skills/SkillsPage.tsx b/frontend/src/views/skills/SkillsPage.tsx index c9e54ff8e..7a2f6643f 100644 --- a/frontend/src/views/skills/SkillsPage.tsx +++ b/frontend/src/views/skills/SkillsPage.tsx @@ -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, @@ -58,16 +59,22 @@ 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', +] const PARTNER_BRANDS: Record = { bankr: { label: 'Bankr', asset: bankrSymbolUrl }, + capminal: { label: 'Capminal', asset: capminalSymbolUrl }, robinhood: { label: 'Robinhood', asset: robinhoodSymbolUrl }, } @@ -326,10 +333,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('all') const [communityCat, setCommunityCat] = useState('all') const [githubUrl, setGithubUrl] = useState('') @@ -388,6 +397,21 @@ export function SkillsPage() { }, }) + const capminalSnapshot = useQuery({ + queryKey: ['skills.search', 'capminal'], + enabled: SHOW_CAPMINAL && tab === 'capminal', + refetchOnWindowFocus: false, + queryFn: async () => { + await rpc.waitForConnection() + const data = await rpc.call('skills.search', { + query: '', + limit: 500, + source: 'capminal', + }) + return data.results ?? [] + }, + }) + const communitySnapshot = useQuery({ queryKey: ['skills.search', 'community'], enabled: tab === 'community', @@ -395,7 +419,7 @@ export function SkillsPage() { queryFn: async () => { await rpc.waitForConnection() const data = await rpc.call('skills.search', { query: '', limit: 500 }) - return communityFilter(data.results ?? [], SHOW_BANKR) + return communityFilter(data.results ?? [], SHOW_BANKR, SHOW_CAPMINAL) }, }) @@ -412,7 +436,7 @@ export function SkillsPage() { query: communityQuery, limit: 100, }) - return communityFilter(data.results ?? [], SHOW_BANKR) + return communityFilter(data.results ?? [], SHOW_BANKR, SHOW_CAPMINAL) }, }) @@ -548,6 +572,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() @@ -592,6 +617,16 @@ export function SkillsPage() { onSelect={setTab} /> ) : null} + {SHOW_CAPMINAL ? ( + } + onSelect={setTab} + /> + ) : null} ) : null} + {SHOW_CAPMINAL && tab === 'capminal' ? ( + setDialog({ kind: 'registry', group: 'capminal', key })} + onInstall={runInstall} + /> + ) : null} + {tab === 'robinhood' ? ( registryKey(r) === dialog.key) if (!item) return null return ( diff --git a/frontend/src/views/skills/logic.test.ts b/frontend/src/views/skills/logic.test.ts index c78afe14d..bb97da5fd 100644 --- a/frontend/src/views/skills/logic.test.ts +++ b/frontend/src/views/skills/logic.test.ts @@ -180,12 +180,18 @@ describe('robinhoodEmptyMessage', () => { const item = (o: Partial): 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']) }) }) @@ -241,6 +247,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', () => { diff --git a/frontend/src/views/skills/logic.ts b/frontend/src/views/skills/logic.ts index 930083669..d65f92698 100644 --- a/frontend/src/views/skills/logic.ts +++ b/frontend/src/views/skills/logic.ts @@ -108,6 +108,7 @@ export const CAT_LABEL: Record = { nft: 'NFT', dev: 'Dev tools', infra: 'Infra', + crypto: 'Crypto', other: 'Other', } @@ -281,8 +282,19 @@ export function robinhoodEmptyMessage(filterText: string, statusFilter: StatusFi * skills.js:503-505 — when the dedicated Bankr tab is showing, Community * excludes source==='bankr' rows; otherwise Bankr falls through into Community. */ -export function communityFilter(results: RegistryItem[], showBankr: boolean): RegistryItem[] { - return showBankr ? results.filter((r) => r.source !== 'bankr') : results +export function communityFilter( + results: RegistryItem[], + showBankr: boolean, + showCapminal: boolean, +): RegistryItem[] { + let out = results + if (showBankr) { + out = out.filter((r) => r.source !== 'bankr') + } + if (showCapminal) { + out = out.filter((r) => r.source !== 'capminal') + } + return out } /** skills.js:560-564 — category → count map over a registry list. */ @@ -347,12 +359,15 @@ export function filterRegistry( } /** skills.js:622-626 — the empty message for a registry group + query. */ -export function registryEmptyMessage(group: 'bankr' | 'community', query: string): string { +export function registryEmptyMessage( + group: 'bankr' | 'capminal' | 'community', + query: string, +): string { const q = (query || '').trim() if (q) return `No skills match ${q}.` - return group === 'bankr' - ? 'No Bankr skills available right now.' - : 'No community skills available right now.' + if (group === 'bankr') return 'No Bankr skills available right now.' + if (group === 'capminal') return 'No Capminal skills available right now.' + return 'No community skills available right now.' } /** skills.js:662,715,283 — the stable identifier key for a registry row. */ diff --git a/src/agentos/skills/hub/capminal.py b/src/agentos/skills/hub/capminal.py new file mode 100644 index 000000000..20f200e3f --- /dev/null +++ b/src/agentos/skills/hub/capminal.py @@ -0,0 +1,211 @@ +"""Capminal skill source — browses and installs skills from Capminal. + +The Capminal repository (https://github.com/capminal-skills/skills) publishes each skill +as a directory containing ``SKILL.md`` + ``_meta.json``. This source reads the +metadata live from GitHub so users can browse and install. Downloading and installation +are delegated to :class:`GitHubSource` via the parsed ``installSource.githubUrl`` +identifier URL. +""" + +from __future__ import annotations + +import asyncio +import json +import re +import time +from collections.abc import Sequence + +import structlog + +from agentos.env import trust_env as _trust_env +from agentos.skills.hub.github import GitHubSource, _frontmatter_field +from agentos.skills.hub.source import SkillBundle, SkillMeta, SkillSource + +log = structlog.get_logger(__name__) + +_DEFAULT_REPO = "capminal-skills/skills" +_DEFAULT_REF = "main" +# Only these skills are loaded from capminal-skills/skills. +_ALLOWED_SLUGS: tuple[str, ...] = ("capminal", "contract-interaction", "neuron-branch-stats") +_CAPMINAL_EMOJI = "🤖" +_CATALOG_TTL_SECONDS = 15 * 60 +_FAILURE_RETRY_SECONDS = 60 +_CATALOG_CONCURRENCY = 16 + +_TOKEN_RE = re.compile(r"[a-z0-9]+") + + +def _matches(meta: SkillMeta, query: str) -> bool: + q = query.strip().lower() + if not q: + return True + haystack = " ".join( + [meta.name, meta.provider, meta.category, meta.description, *meta.tags] + ).lower() + return q in haystack + + +class CapminalSource(SkillSource): + """Skill source backed by the capminal-skills/skills GitHub catalog.""" + + def __init__( + self, + token: str | None = None, + *, + repo: str = _DEFAULT_REPO, + ref: str = _DEFAULT_REF, + allowlist: Sequence[str] = _ALLOWED_SLUGS, + ) -> None: + self._github = GitHubSource(token=token) + self._repo = repo + self._ref = ref + self._allowlist = tuple(allowlist) + self._raw_base = f"https://raw.githubusercontent.com/{repo}/{ref}" + self._cache_metas: list[SkillMeta] | None = None + self._cache_at = 0.0 + self._last_failure_at = 0.0 + self._lock = asyncio.Lock() + + @property + def source_id(self) -> str: + return "capminal" + + @property + def trust_level(self) -> str: + return "community" + + async def search(self, query: str, limit: int = 200) -> list[SkillMeta]: + """List Capminal skills (all when query is empty; filtered otherwise).""" + metas = await self._load_catalog() + results = [m for m in metas if _matches(m, query)] + return results[:limit] + + async def inspect(self, identifier: str) -> SkillMeta | None: + return await self._github.inspect(identifier) + + async def fetch(self, identifier: str) -> SkillBundle | None: + return await self._github.fetch(identifier) + + async def _load_catalog(self) -> list[SkillMeta]: + async with self._lock: + now = time.monotonic() + if self._cache_metas is not None and (now - self._cache_at) < _CATALOG_TTL_SECONDS: + return self._cache_metas + # Negative cache: after a failed fetch, serve what we have without hammering GitHub. + if (now - self._last_failure_at) < _FAILURE_RETRY_SECONDS: + return self._cache_metas or [] + + metas = await self._fetch_catalog() + if metas is None: + self._last_failure_at = time.monotonic() + return self._cache_metas or [] + + self._cache_metas = metas + self._cache_at = time.monotonic() + return metas + + async def _fetch_catalog(self) -> list[SkillMeta] | None: + """Fetch _meta.json + SKILL.md for each allowlisted slug directly.""" + import httpx + + if not self._allowlist: + return [] + + try: + async with httpx.AsyncClient(timeout=15, trust_env=_trust_env()) as client: + sem = asyncio.Semaphore(_CATALOG_CONCURRENCY) + + async def _load_one(slug: str) -> SkillMeta | None: + async with sem: + return await self._load_catalog_entry(client, slug) + + loaded = await asyncio.gather(*(_load_one(s) for s in self._allowlist)) + except Exception as exc: + log.warning("capminal.fetch_failed", error=str(exc)) + return None + + metas = [m for m in loaded if m is not None] + if not metas: + return None + metas.sort(key=lambda m: m.name) + return metas + + async def _load_catalog_entry(self, client, slug: str) -> SkillMeta | None: + """Fetch and parse one skill's _meta.json, then SKILL.md. Skips on error.""" + headers = self._github._headers() + meta_url = f"{self._raw_base}/{slug}/_meta.json" + try: + resp = await client.get(meta_url, headers=headers) + resp.raise_for_status() + meta_data = json.loads(resp.content) + except Exception as exc: + log.warning("capminal.meta_failed", slug=slug, error=str(exc)) + return None + if not isinstance(meta_data, dict): + return None + + skill_md_url = f"{self._raw_base}/{slug}/SKILL.md" + skill_md_content = await self._load_skill_md(client, slug, skill_md_url, headers) + + return self._meta_from_catalog(slug, meta_data, skill_md_content) + + async def _load_skill_md(self, client, slug: str, url: str, headers: dict) -> str: + """Fetch SKILL.md and read its content. Empty on error.""" + try: + resp = await client.get(url, headers=headers) + resp.raise_for_status() + except Exception as exc: + log.warning("capminal.skill_md_failed", slug=slug, error=str(exc)) + return "" + try: + return str(resp.content.decode("utf-8")) + except UnicodeDecodeError: + return "" + + def _meta_from_catalog( + self, slug: str, catalog: dict, skill_md_content: str + ) -> SkillMeta | None: + """Build a browse-time SkillMeta from parsed _meta.json and SKILL.md content.""" + install_source = catalog.get("installSource") + if not isinstance(install_source, dict): + return None + github_url = install_source.get("githubUrl") + if not github_url or not isinstance(github_url, str): + return None + + name = _frontmatter_field(skill_md_content, "name") or slug + description = ( + _frontmatter_field(skill_md_content, "description") + or catalog.get("description") + or "" + ) + version = ( + install_source.get("version") + or _frontmatter_field(skill_md_content, "version") + or "" + ) + author = _frontmatter_field(skill_md_content, "author") or "capminal" + + tags_raw = _frontmatter_field(skill_md_content, "tags") + tags = [] + if tags_raw: + t_str = tags_raw.strip() + if t_str.startswith("[") and t_str.endswith("]"): + t_str = t_str[1:-1] + tags = [t.strip() for t in t_str.split(",") if t.strip()] + + return SkillMeta( + name=name, + description=description, + version=version, + author=author, + source_id="capminal", + trust_level="community", + identifier=github_url, + homepage=github_url, + tags=tags, + provider="Capminal", + logo="", + emoji=_CAPMINAL_EMOJI, + category="crypto", + ) diff --git a/src/agentos/skills/hub/defaults.py b/src/agentos/skills/hub/defaults.py index 92bcf9a4a..d1e5ddf67 100644 --- a/src/agentos/skills/hub/defaults.py +++ b/src/agentos/skills/hub/defaults.py @@ -7,6 +7,7 @@ from agentos.paths import default_agentos_home from agentos.skills.hub.bankr import BankrSource +from agentos.skills.hub.capminal import CapminalSource from agentos.skills.hub.clawhub import ClawHubSource from agentos.skills.hub.github import GitHubSource from agentos.skills.hub.installer import SkillInstaller @@ -29,6 +30,7 @@ def get_default_skill_router() -> SourceRouter: # BankrBot/skills directories as bare, unenriched rows that would # otherwise shadow the Bankr rows carrying category/logo/setup. BankrSource(token=os.environ.get("GITHUB_TOKEN")), + CapminalSource(token=os.environ.get("GITHUB_TOKEN")), GitHubSource(token=os.environ.get("GITHUB_TOKEN")), ] _default_router = SourceRouter(sources) diff --git a/tests/test_skills_hub_capminal.py b/tests/test_skills_hub_capminal.py new file mode 100644 index 000000000..191faed36 --- /dev/null +++ b/tests/test_skills_hub_capminal.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +import json +from typing import Any + +import pytest + +from agentos.skills.hub.capminal import CapminalSource + +_FIXTURE_SLUGS = ("capminal", "contract-interaction", "neuron-branch-stats", "broken") + + +def _meta(slug: str, *, version: str = "0.1.0", github_url: str | None = None) -> bytes: + if github_url is None: + github_url = f"https://github.com/capminal-skills/{slug}" + return json.dumps( + { + "name": slug, + "package": slug, + "description": f"Capminal description for {slug}", + "installSource": { + "version": version, + "githubUrl": github_url, + }, + } + ).encode("utf-8") + + +class _Response: + def __init__( + self, + *, + json_data: dict[str, Any] | None = None, + content: bytes = b"", + status_code: int = 200, + ) -> None: + self._json_data = json_data or {} + self.content = content + self.text = content.decode("utf-8", errors="replace") + self.status_code = status_code + + def json(self) -> dict[str, Any]: + return self._json_data + + def raise_for_status(self) -> None: + if self.status_code >= 400: + raise RuntimeError(f"HTTP {self.status_code}") + + +class _AsyncClient: + """Mocks the per-skill capminal-skills/skills _meta.json + SKILL.md fetches.""" + + metas = { + "capminal": _meta("capminal"), + "contract-interaction": _meta("contract-interaction"), + "neuron-branch-stats": _meta("neuron-branch-stats"), + "broken": b"{ not json", + } + skill_mds = { + "capminal": ( + b"---\nname: capminal\ndescription: Cap World interaction\n" + b"tags: [capminal, crypto, wallet]\n---\n# Capminal\n" + ), + "contract-interaction": ( + b"---\nname: contract-interaction\ndescription: Smart contract interaction\n" + b"tags: [contract, crypto]\n---\n# Contract\n" + ), + "neuron-branch-stats": ( + b"---\nname: neuron-branch-stats\ndescription: Stats for neuron branch\n" + b"tags: [stats, branch]\n---\n# Stats\n" + ), + } + meta_calls = 0 + skill_md_calls = 0 + + def __init__(self, *args: Any, **kwargs: Any) -> None: + pass + + async def __aenter__(self) -> _AsyncClient: + return self + + async def __aexit__(self, *args: Any) -> None: + return None + + async def get(self, url: str, **kwargs: Any) -> _Response: + if "/git/trees/" in url: + raise AssertionError(f"tree API must not be called: {url}") + marker = "raw.githubusercontent.com/capminal-skills/skills/main/" + if marker in url: + slug = url.split(marker, 1)[1].split("/", 1)[0] + if url.endswith("/SKILL.md"): + type(self).skill_md_calls += 1 + content = self.skill_mds.get(slug) + if content is None: + return _Response(status_code=404) + return _Response(content=content) + type(self).meta_calls += 1 + content = self.metas.get(slug) + if content is None or content == b"{ not json": + return _Response(content=content or b"") + return _Response(content=content) + raise AssertionError(f"unexpected URL: {url}") + + +@pytest.fixture(autouse=True) +def _reset_client_counters() -> None: + _AsyncClient.meta_calls = 0 + _AsyncClient.skill_md_calls = 0 + + +def _source() -> CapminalSource: + return CapminalSource(allowlist=_FIXTURE_SLUGS) + + +@pytest.mark.asyncio +async def test_search_empty_query_lists_all_capminal_skills(monkeypatch) -> None: + import httpx + + monkeypatch.setattr(httpx, "AsyncClient", _AsyncClient) + + results = await _source().search("") + + names = {r.name for r in results} + # capminal, contract-interaction, neuron-branch-stats kept; broken JSON skipped. + assert names == {"capminal", "contract-interaction", "neuron-branch-stats"} + assert all(r.source_id == "capminal" for r in results) + assert all(r.trust_level == "community" for r in results) + assert all(r.category == "crypto" for r in results) + + +@pytest.mark.asyncio +async def test_search_builds_provider_and_identifier(monkeypatch) -> None: + import httpx + + monkeypatch.setattr(httpx, "AsyncClient", _AsyncClient) + + results = await _source().search("") + by_name = {r.name: r for r in results} + + capminal = by_name["capminal"] + assert capminal.provider == "Capminal" + assert capminal.logo == "" + assert capminal.identifier == "https://github.com/capminal-skills/capminal" + assert capminal.emoji == "🤖" + assert capminal.tags == ["capminal", "crypto", "wallet"] + + +@pytest.mark.asyncio +async def test_search_delegates_inspect_and_fetch(monkeypatch) -> None: + # Verify that inspect and fetch calls on CapminalSource are delegated to GitHubSource + src = _source() + called_inspect = False + called_fetch = False + + async def mock_inspect(self_source, identifier): + nonlocal called_inspect + called_inspect = True + return None + + async def mock_fetch(self_source, identifier): + nonlocal called_fetch + called_fetch = True + return None + + from agentos.skills.hub.github import GitHubSource + monkeypatch.setattr(GitHubSource, "inspect", mock_inspect) + monkeypatch.setattr(GitHubSource, "fetch", mock_fetch) + + await src.inspect("https://github.com/capminal-skills/capminal") + assert called_inspect + + await src.fetch("https://github.com/capminal-skills/capminal") + assert called_fetch From c857a182045105cd5f5ab859f524ad7a57b4797e Mon Sep 17 00:00:00 2001 From: Bukee Date: Mon, 27 Jul 2026 13:43:22 -0700 Subject: [PATCH 2/7] fix(skills): resolve compile error from registry panel signature change --- frontend/src/views/skills/SkillsPage.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/views/skills/SkillsPage.tsx b/frontend/src/views/skills/SkillsPage.tsx index b3b428d4e..227c6d987 100644 --- a/frontend/src/views/skills/SkillsPage.tsx +++ b/frontend/src/views/skills/SkillsPage.tsx @@ -937,7 +937,9 @@ export function SkillsPage() { onCategory={setCapminalCat} forceArmed={forceArmed} busyKeys={busyKeys} - onOpen={(key) => setDialog({ kind: 'registry', group: 'capminal', key })} + onOpen={(item) => + setDialog({ kind: 'registry', group: 'capminal', key: registryKey(item), item }) + } onInstall={runInstall} /> ) : null} From 4a56dbf8405508e78f9c2565a5853efa02f1b1af Mon Sep 17 00:00:00 2001 From: Bukee Date: Tue, 28 Jul 2026 07:54:11 -0700 Subject: [PATCH 3/7] fix(skills): resolve code review findings for Capminal Skills --- CHANGELOG.md | 4 ++ frontend/src/assets/capminal-symbol.svg | 9 ++- src/agentos/skills/hub/capminal.py | 68 ++++++++++++-------- src/agentos/skills/publishers.py | 6 ++ tests/test_skills_hub_capminal.py | 83 ++++++++++++++++--------- 5 files changed, 111 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02a24d557..9c0024908 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.27] - 2026-07-27 ### Added diff --git a/frontend/src/assets/capminal-symbol.svg b/frontend/src/assets/capminal-symbol.svg index d5adbf8c1..e3a910fc3 100644 --- a/frontend/src/assets/capminal-symbol.svg +++ b/frontend/src/assets/capminal-symbol.svg @@ -1,7 +1,6 @@ - - - - - + + + + diff --git a/src/agentos/skills/hub/capminal.py b/src/agentos/skills/hub/capminal.py index 20f200e3f..628510e06 100644 --- a/src/agentos/skills/hub/capminal.py +++ b/src/agentos/skills/hub/capminal.py @@ -1,10 +1,9 @@ """Capminal skill source — browses and installs skills from Capminal. -The Capminal repository (https://github.com/capminal-skills/skills) publishes each skill +The Capminal repository (https://github.com/Capminal/agent-skills) publishes each skill as a directory containing ``SKILL.md`` + ``_meta.json``. This source reads the metadata live from GitHub so users can browse and install. Downloading and installation -are delegated to :class:`GitHubSource` via the parsed ``installSource.githubUrl`` -identifier URL. +are delegated to :class:`GitHubSource` via the parsed identifier URL. """ from __future__ import annotations @@ -18,15 +17,15 @@ import structlog from agentos.env import trust_env as _trust_env -from agentos.skills.hub.github import GitHubSource, _frontmatter_field +from agentos.skills.hub.github import GitHubSource, _frontmatter_field, _parse_identifier from agentos.skills.hub.source import SkillBundle, SkillMeta, SkillSource log = structlog.get_logger(__name__) -_DEFAULT_REPO = "capminal-skills/skills" +_DEFAULT_REPO = "Capminal/agent-skills" _DEFAULT_REF = "main" -# Only these skills are loaded from capminal-skills/skills. -_ALLOWED_SLUGS: tuple[str, ...] = ("capminal", "contract-interaction", "neuron-branch-stats") +# Only these skills are loaded from Capminal/agent-skills. +_ALLOWED_SLUGS: tuple[str, ...] = ("capminal", "contract-interaction", "morse-launch-b20") _CAPMINAL_EMOJI = "🤖" _CATALOG_TTL_SECONDS = 15 * 60 _FAILURE_RETRY_SECONDS = 60 @@ -46,7 +45,7 @@ def _matches(meta: SkillMeta, query: str) -> bool: class CapminalSource(SkillSource): - """Skill source backed by the capminal-skills/skills GitHub catalog.""" + """Skill source backed by the Capminal/agent-skills GitHub catalog.""" def __init__( self, @@ -74,16 +73,34 @@ def source_id(self) -> str: def trust_level(self) -> str: return "community" + def _skill_url(self, slug: str) -> str: + return f"https://github.com/{self._repo}/tree/{self._ref}/{slug}" + async def search(self, query: str, limit: int = 200) -> list[SkillMeta]: """List Capminal skills (all when query is empty; filtered otherwise).""" metas = await self._load_catalog() results = [m for m in metas if _matches(m, query)] return results[:limit] + def _is_allowlisted(self, identifier: str) -> bool: + """Return True when ``identifier`` names an allowlisted skill in this repo.""" + ref = _parse_identifier(identifier) + if ref is None: + return False + if ref.repo_full.lower() != self._repo.lower(): + return False + return ref.skill_dir.strip("/") in self._allowlist + async def inspect(self, identifier: str) -> SkillMeta | None: + if not self._is_allowlisted(identifier): + log.warning("capminal.identifier_rejected", op="inspect") + return None return await self._github.inspect(identifier) async def fetch(self, identifier: str) -> SkillBundle | None: + if not self._is_allowlisted(identifier): + log.warning("capminal.identifier_rejected", op="fetch") + return None return await self._github.fetch(identifier) async def _load_catalog(self) -> list[SkillMeta]: @@ -166,25 +183,25 @@ def _meta_from_catalog( self, slug: str, catalog: dict, skill_md_content: str ) -> SkillMeta | None: """Build a browse-time SkillMeta from parsed _meta.json and SKILL.md content.""" - install_source = catalog.get("installSource") - if not isinstance(install_source, dict): - return None - github_url = install_source.get("githubUrl") - if not github_url or not isinstance(github_url, str): - return None - - name = _frontmatter_field(skill_md_content, "name") or slug + identifier = self._skill_url(slug) + display_name = str(catalog.get("displayName") or slug) + name = _frontmatter_field(skill_md_content, "name") or display_name description = ( _frontmatter_field(skill_md_content, "description") - or catalog.get("description") - or "" + or str(catalog.get("description") or "") ) - version = ( - install_source.get("version") - or _frontmatter_field(skill_md_content, "version") - or "" + latest_release = catalog.get("latestRelease") + version = "" + if isinstance(latest_release, dict): + version = str(latest_release.get("version") or "") + if not version: + version = _frontmatter_field(skill_md_content, "version") or "" + + author = ( + str(catalog.get("owner") or "") + or _frontmatter_field(skill_md_content, "author") + or "capminal" ) - author = _frontmatter_field(skill_md_content, "author") or "capminal" tags_raw = _frontmatter_field(skill_md_content, "tags") tags = [] @@ -201,11 +218,12 @@ def _meta_from_catalog( author=author, source_id="capminal", trust_level="community", - identifier=github_url, - homepage=github_url, + identifier=identifier, + homepage=identifier, tags=tags, provider="Capminal", logo="", emoji=_CAPMINAL_EMOJI, category="crypto", ) + diff --git a/src/agentos/skills/publishers.py b/src/agentos/skills/publishers.py index bde745315..97004bd85 100644 --- a/src/agentos/skills/publishers.py +++ b/src/agentos/skills/publishers.py @@ -43,6 +43,12 @@ url="https://github.com/BankrBot/skills", logo="", ), + "capminal": SkillPublisher( + id="capminal", + name="Capminal", + url="https://github.com/Capminal/agent-skills", + logo="", + ), } diff --git a/tests/test_skills_hub_capminal.py b/tests/test_skills_hub_capminal.py index 191faed36..df2d3ab71 100644 --- a/tests/test_skills_hub_capminal.py +++ b/tests/test_skills_hub_capminal.py @@ -7,20 +7,24 @@ from agentos.skills.hub.capminal import CapminalSource -_FIXTURE_SLUGS = ("capminal", "contract-interaction", "neuron-branch-stats", "broken") +_FIXTURE_SLUGS = ("capminal", "contract-interaction", "morse-launch-b20", "broken") -def _meta(slug: str, *, version: str = "0.1.0", github_url: str | None = None) -> bytes: - if github_url is None: - github_url = f"https://github.com/capminal-skills/{slug}" +def _meta( + slug: str, + *, + owner: str = "AndreaPN", + display_name: str | None = None, + version: str = "0.37.0", +) -> bytes: return json.dumps( { - "name": slug, + "owner": owner, "package": slug, - "description": f"Capminal description for {slug}", - "installSource": { + "displayName": display_name or slug.capitalize(), + "latestRelease": { "version": version, - "githubUrl": github_url, + "publishedAt": 1748822400000, }, } ).encode("utf-8") @@ -48,12 +52,12 @@ def raise_for_status(self) -> None: class _AsyncClient: - """Mocks the per-skill capminal-skills/skills _meta.json + SKILL.md fetches.""" + """Mocks the per-skill Capminal/agent-skills _meta.json + SKILL.md fetches.""" metas = { "capminal": _meta("capminal"), "contract-interaction": _meta("contract-interaction"), - "neuron-branch-stats": _meta("neuron-branch-stats"), + "morse-launch-b20": _meta("morse-launch-b20"), "broken": b"{ not json", } skill_mds = { @@ -65,9 +69,9 @@ class _AsyncClient: b"---\nname: contract-interaction\ndescription: Smart contract interaction\n" b"tags: [contract, crypto]\n---\n# Contract\n" ), - "neuron-branch-stats": ( - b"---\nname: neuron-branch-stats\ndescription: Stats for neuron branch\n" - b"tags: [stats, branch]\n---\n# Stats\n" + "morse-launch-b20": ( + b"---\nname: morse-launch-b20\ndescription: Morse launch B20 skill\n" + b"tags: [morse, launch]\n---\n# Morse\n" ), } meta_calls = 0 @@ -85,7 +89,7 @@ async def __aexit__(self, *args: Any) -> None: async def get(self, url: str, **kwargs: Any) -> _Response: if "/git/trees/" in url: raise AssertionError(f"tree API must not be called: {url}") - marker = "raw.githubusercontent.com/capminal-skills/skills/main/" + marker = "raw.githubusercontent.com/Capminal/agent-skills/main/" if marker in url: slug = url.split(marker, 1)[1].split("/", 1)[0] if url.endswith("/SKILL.md"): @@ -121,8 +125,8 @@ async def test_search_empty_query_lists_all_capminal_skills(monkeypatch) -> None results = await _source().search("") names = {r.name for r in results} - # capminal, contract-interaction, neuron-branch-stats kept; broken JSON skipped. - assert names == {"capminal", "contract-interaction", "neuron-branch-stats"} + # capminal, contract-interaction, morse-launch-b20 kept; broken JSON skipped. + assert names == {"capminal", "contract-interaction", "morse-launch-b20"} assert all(r.source_id == "capminal" for r in results) assert all(r.trust_level == "community" for r in results) assert all(r.category == "crypto" for r in results) @@ -140,34 +144,55 @@ async def test_search_builds_provider_and_identifier(monkeypatch) -> None: capminal = by_name["capminal"] assert capminal.provider == "Capminal" assert capminal.logo == "" - assert capminal.identifier == "https://github.com/capminal-skills/capminal" + assert capminal.identifier == "https://github.com/Capminal/agent-skills/tree/main/capminal" assert capminal.emoji == "🤖" assert capminal.tags == ["capminal", "crypto", "wallet"] @pytest.mark.asyncio -async def test_search_delegates_inspect_and_fetch(monkeypatch) -> None: - # Verify that inspect and fetch calls on CapminalSource are delegated to GitHubSource +async def test_inspect_and_fetch_enforce_allowlist(monkeypatch) -> None: src = _source() - called_inspect = False - called_fetch = False + inspected_id = None + fetched_id = None async def mock_inspect(self_source, identifier): - nonlocal called_inspect - called_inspect = True + nonlocal inspected_id + inspected_id = identifier return None async def mock_fetch(self_source, identifier): - nonlocal called_fetch - called_fetch = True + nonlocal fetched_id + fetched_id = identifier return None from agentos.skills.hub.github import GitHubSource + monkeypatch.setattr(GitHubSource, "inspect", mock_inspect) monkeypatch.setattr(GitHubSource, "fetch", mock_fetch) - await src.inspect("https://github.com/capminal-skills/capminal") - assert called_inspect + # Allowed identifier delegates successfully + allowed_id = "https://github.com/Capminal/agent-skills/tree/main/capminal" + await src.inspect(allowed_id) + assert inspected_id == allowed_id + + await src.fetch(allowed_id) + assert fetched_id == allowed_id + + # Disallowed repo is rejected without delegating + inspected_id = None + fetched_id = None + disallowed_repo = "https://github.com/attacker/malicious/tree/main/capminal" + assert await src.inspect(disallowed_repo) is None + assert inspected_id is None + + assert await src.fetch(disallowed_repo) is None + assert fetched_id is None + + # Disallowed slug in Capminal repo is rejected without delegating + disallowed_slug = "https://github.com/Capminal/agent-skills/tree/main/unapproved" + assert await src.inspect(disallowed_slug) is None + assert inspected_id is None + + assert await src.fetch(disallowed_slug) is None + assert fetched_id is None - await src.fetch("https://github.com/capminal-skills/capminal") - assert called_fetch From d60c5ec4b718cedbae8fd290be863db1b79d33f0 Mon Sep 17 00:00:00 2001 From: Bukee Date: Tue, 28 Jul 2026 09:22:50 -0700 Subject: [PATCH 4/7] fix(webui): prevent skill cards from overflowing grid tracks (#135) --- frontend/src/views/skills/skills-css.test.ts | 8 ++++++++ frontend/src/views/skills/skills.css | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/frontend/src/views/skills/skills-css.test.ts b/frontend/src/views/skills/skills-css.test.ts index a9ff7c965..c8e582dc6 100644 --- a/frontend/src/views/skills/skills-css.test.ts +++ b/frontend/src/views/skills/skills-css.test.ts @@ -36,4 +36,12 @@ describe('Skills directory CSS contract', () => { /@media \(prefers-reduced-motion: reduce\)[\s\S]*?\.control-surface \.sk-card:hover,[\s\S]*?transform: none;/, ) }) + + it('prevents skill cards from overflowing grid tracks when skill names are long', () => { + expect(css).toMatch(/\.sk-card \{[\s\S]*?min-width:\s*0;/) + expect(css).toMatch(/\.sk-card__head \{[\s\S]*?min-width:\s*0;/) + expect(css).toMatch( + /\.sk-card__name \{[\s\S]*?overflow:\s*hidden;[\s\S]*?text-overflow:\s*ellipsis;[\s\S]*?white-space:\s*nowrap;/, + ) + }) }) diff --git a/frontend/src/views/skills/skills.css b/frontend/src/views/skills/skills.css index c84a8414f..eff493b89 100644 --- a/frontend/src/views/skills/skills.css +++ b/frontend/src/views/skills/skills.css @@ -237,6 +237,7 @@ border-radius: var(--radius); background: var(--background); cursor: pointer; + min-width: 0; } .sk-card:hover { border-color: var(--dim); @@ -245,6 +246,7 @@ display: flex; align-items: center; gap: 8px; + min-width: 0; } .sk-card__dot { width: 8px; @@ -267,6 +269,11 @@ font-size: 0.8125rem; font-weight: 600; color: var(--foreground); + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .sk-card__emoji { font-size: 0.85rem; @@ -1010,6 +1017,7 @@ } .control-surface .sk-card { + min-width: 0; min-height: 10.5rem; gap: 0.75rem; border-color: var(--border); From 5ef311831e9c8289bb3278adacd37ee95d3ec594 Mon Sep 17 00:00:00 2001 From: Bukee Date: Wed, 29 Jul 2026 10:54:04 -0700 Subject: [PATCH 5/7] chore(packaging): cap runtime dependencies in pyproject.toml (#153) --- pyproject.toml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3379b1c86..82fa0370e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,15 +17,15 @@ classifiers = [ keywords = ["agent", "llm", "mcp", "gateway", "router", "chatbot"] requires-python = ">=3.12" dependencies = [ - "starlette>=0.40", + "starlette>=0.40,<2.0", "python-multipart>=0.0.20", - "uvicorn[standard]>=0.30", - "pydantic>=2.0", - "pydantic-settings>=2.0", - "sqlmodel>=0.0.20", + "uvicorn[standard]>=0.30,<1.0", + "pydantic>=2.0,<3.0", + "pydantic-settings>=2.0,<3.0", + "sqlmodel>=0.0.20,<0.1.0", "anyio>=4.0", - "httpx>=0.27", - "mcp>=1.2.0", + "httpx>=0.27,<0.29", + "mcp>=1.2.0,<2.0", "brotli>=1.1", "jinja2>=3.1", "structlog>=24.0", From e0008f0258cc0428847a9b6defb0c80f8f6a5ae8 Mon Sep 17 00:00:00 2001 From: Bukee Date: Wed, 29 Jul 2026 11:24:17 -0700 Subject: [PATCH 6/7] chore(packaging): sync uv.lock with pyproject.toml dependency bounds --- uv.lock | 64 ++++++++++++++++++++++++++++----------------------------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/uv.lock b/uv.lock index e80d62f24..0220ff209 100644 --- a/uv.lock +++ b/uv.lock @@ -550,7 +550,7 @@ name = "cuda-bindings" version = "13.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-pathfinder", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "cuda-pathfinder" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/ce/67/5e7dba1ba576dd73da5dee894ca076ca5e959450dfff66d6d510a255d1f7/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7855c4868aabc0cfae28abbe83d56734bdfbd08f08fc234ac1912a12858bf49", size = 6025351, upload-time = "2026-05-29T23:11:49.685Z" }, @@ -581,43 +581,43 @@ wheels = [ [package.optional-dependencies] cublas = [ - { name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] cudart = [ - { name = "nvidia-cuda-runtime", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cuda-runtime", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] cufft = [ - { name = "nvidia-cufft", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cufft", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] cufile = [ - { name = "nvidia-cufile", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cufile", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] cupti = [ - { name = "nvidia-cuda-cupti", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cuda-cupti", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] curand = [ - { name = "nvidia-curand", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-curand", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] cusolver = [ - { name = "nvidia-cublas", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "nvidia-cusolver", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cusolver", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] cusparse = [ - { name = "nvidia-cusparse", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] nvjitlink = [ - { name = "nvidia-nvjitlink", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] nvrtc = [ - { name = "nvidia-cuda-nvrtc", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] nvtx = [ - { name = "nvidia-nvtx", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvtx", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] [[package]] @@ -1847,7 +1847,7 @@ name = "nvidia-cublas" version = "13.1.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cuda-nvrtc", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-cuda-nvrtc" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, @@ -1886,7 +1886,7 @@ name = "nvidia-cudnn-cu13" version = "9.20.0.48" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-cublas" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, @@ -1898,7 +1898,7 @@ name = "nvidia-cufft" version = "12.0.0.61" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, @@ -1928,9 +1928,9 @@ name = "nvidia-cusolver" version = "12.0.4.66" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "nvidia-cusparse", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "nvidia-nvjitlink", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-cublas" }, + { name = "nvidia-cusparse" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, @@ -1942,7 +1942,7 @@ name = "nvidia-cusparse" version = "12.6.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, @@ -3818,11 +3818,11 @@ requires-dist = [ { name = "cachetools", specifier = ">=5.3" }, { name = "croniter", specifier = ">=2.0" }, { name = "html2text", specifier = ">=2024.2" }, - { name = "httpx", specifier = ">=0.27" }, + { name = "httpx", specifier = ">=0.27,<0.29" }, { name = "jieba", marker = "extra == 'memory'", specifier = ">=0.42" }, { name = "jieba", marker = "extra == 'recommended'", specifier = ">=0.42" }, { name = "jinja2", specifier = ">=3.1" }, - { name = "mcp", specifier = ">=1.2.0" }, + { name = "mcp", specifier = ">=1.2.0,<2.0" }, { name = "mem0ai", marker = "extra == 'mem0'", specifier = ">=0.1.70" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" }, { name = "numpy", marker = "extra == 'ml-router'", specifier = ">=1.26" }, @@ -3833,8 +3833,8 @@ requires-dist = [ { name = "pdfplumber", specifier = ">=0.11" }, { name = "pillow", specifier = ">=10.0" }, { name = "prompt-toolkit", specifier = ">=3.0" }, - { name = "pydantic", specifier = ">=2.0" }, - { name = "pydantic-settings", specifier = ">=2.0" }, + { name = "pydantic", specifier = ">=2.0,<3.0" }, + { name = "pydantic-settings", specifier = ">=2.0,<3.0" }, { name = "pypdf", specifier = ">=4.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.24" }, @@ -3849,8 +3849,8 @@ requires-dist = [ { name = "rich", specifier = ">=13.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.5" }, { name = "sqlite-vec", specifier = ">=0.1" }, - { name = "sqlmodel", specifier = ">=0.0.20" }, - { name = "starlette", specifier = ">=0.40" }, + { name = "sqlmodel", specifier = ">=0.0.20,<0.1.0" }, + { name = "starlette", specifier = ">=0.40,<2.0" }, { name = "structlog", specifier = ">=24.0" }, { name = "tiktoken", marker = "extra == 'memory'", specifier = ">=0.5" }, { name = "tiktoken", marker = "extra == 'recommended'", specifier = ">=0.5" }, @@ -3858,7 +3858,7 @@ requires-dist = [ { name = "tokenizers", marker = "extra == 'recommended'", specifier = ">=0.15" }, { name = "tomli-w", specifier = ">=1.0" }, { name = "typer", specifier = ">=0.12" }, - { name = "uvicorn", extras = ["standard"], specifier = ">=0.30" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.30,<1.0" }, { name = "weasyprint", marker = "extra == 'document-extras'", specifier = ">=60.0" }, { name = "websockets", specifier = ">=13.0" }, { name = "yoyo-migrations", specifier = ">=8.2" }, From c9a1cd869883fa0ae6f8a9acf0dfab48ba5dd48b Mon Sep 17 00:00:00 2001 From: Bukee Date: Wed, 29 Jul 2026 12:49:07 -0700 Subject: [PATCH 7/7] feat(skills): update Capminal brand asset color and fix logo rendering fallbacks --- frontend/src/assets/capminal-symbol.svg | 2 +- frontend/src/views/skills/SkillsPage.test.tsx | 17 +++++++++++++++++ frontend/src/views/skills/SkillsPage.tsx | 3 +++ tests/test_mcp_server/test_fastmcp_app.py | 2 +- 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/frontend/src/assets/capminal-symbol.svg b/frontend/src/assets/capminal-symbol.svg index e3a910fc3..f71eb4e09 100644 --- a/frontend/src/assets/capminal-symbol.svg +++ b/frontend/src/assets/capminal-symbol.svg @@ -1,6 +1,6 @@ - + diff --git a/frontend/src/views/skills/SkillsPage.test.tsx b/frontend/src/views/skills/SkillsPage.test.tsx index ed2d35fb7..402063516 100644 --- a/frontend/src/views/skills/SkillsPage.test.tsx +++ b/frontend/src/views/skills/SkillsPage.test.tsx @@ -141,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. @@ -449,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() diff --git a/frontend/src/views/skills/SkillsPage.tsx b/frontend/src/views/skills/SkillsPage.tsx index 227c6d987..6013d5735 100644 --- a/frontend/src/views/skills/SkillsPage.tsx +++ b/frontend/src/views/skills/SkillsPage.tsx @@ -191,6 +191,9 @@ function LogoBadge({ item, cls }: { item: RegistryItem; cls: string }) { if (item.source?.toLowerCase() === 'bankr') { return } + if (item.source?.toLowerCase() === 'capminal') { + return + } return {initials(item.provider || item.name)} } return ( diff --git a/tests/test_mcp_server/test_fastmcp_app.py b/tests/test_mcp_server/test_fastmcp_app.py index 377a4146a..8b84c256b 100644 --- a/tests/test_mcp_server/test_fastmcp_app.py +++ b/tests/test_mcp_server/test_fastmcp_app.py @@ -102,4 +102,4 @@ def test_base_mcp_dependency_minimum_supports_fastmcp() -> None: pyproject = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8")) dependencies = pyproject["project"]["dependencies"] - assert "mcp>=1.2.0" in dependencies + assert any(dep.startswith("mcp>=1.2.0") for dep in dependencies)