diff --git a/CHANGELOG.md b/CHANGELOG.md index ce0802a6c..cc3e2f6d3 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.29] - 2026-07-29 ### Added diff --git a/frontend/src/assets/capminal-symbol.svg b/frontend/src/assets/capminal-symbol.svg new file mode 100644 index 000000000..f71eb4e09 --- /dev/null +++ b/frontend/src/assets/capminal-symbol.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/src/views/skills/SkillsPage.test.tsx b/frontend/src/views/skills/SkillsPage.test.tsx index 1d8a24682..402063516 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', () => ({ @@ -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. @@ -336,6 +344,7 @@ describe('SkillsPage', () => { expect(tabs.map((tab) => tab.getAttribute('aria-label'))).toEqual([ 'Installed', 'Bankr', + 'Capminal', 'Robinhood', 'Community', ]) @@ -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') @@ -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 () => { @@ -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() @@ -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', + }) + }) }) diff --git a/frontend/src/views/skills/SkillsPage.tsx b/frontend/src/views/skills/SkillsPage.tsx index a6f34e523..6013d5735 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, @@ -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 = { bankr: { label: 'Bankr', asset: bankrSymbolUrl }, + capminal: { label: 'Capminal', asset: capminalSymbolUrl }, robinhood: { label: 'Robinhood', asset: robinhoodSymbolUrl }, } @@ -184,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 ( @@ -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('all') const [communityCat, setCommunityCat] = useState('all') const [githubUrl, setGithubUrl] = useState('') @@ -482,6 +494,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', @@ -489,7 +516,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) }, }) @@ -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) }, }) @@ -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 @@ -724,7 +763,11 @@ export function SkillsPage() { */ const registryItemFor = (d: Extract): 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 @@ -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() @@ -778,6 +822,16 @@ export function SkillsPage() { onSelect={setTab} /> ) : null} + {SHOW_CAPMINAL ? ( + } + onSelect={setTab} + /> + ) : null} ) : null} + {SHOW_CAPMINAL && tab === 'capminal' ? ( + + setDialog({ kind: 'registry', group: 'capminal', key: registryKey(item), item }) + } + onInstall={runInstall} + /> + ) : null} + {tab === 'robinhood' ? ( { 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']) }) }) @@ -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', () => { diff --git a/frontend/src/views/skills/logic.ts b/frontend/src/views/skills/logic.ts index f5bee0ced..53a5d1564 100644 --- a/frontend/src/views/skills/logic.ts +++ b/frontend/src/views/skills/logic.ts @@ -168,6 +168,7 @@ export const CAT_LABEL: Record = { nft: 'NFT', dev: 'Dev tools', infra: 'Infra', + crypto: 'Crypto', other: 'Other', } @@ -472,8 +473,19 @@ export function partnerEmptyMessage( * 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. */ @@ -561,12 +573,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/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); 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", diff --git a/src/agentos/skills/hub/capminal.py b/src/agentos/skills/hub/capminal.py new file mode 100644 index 000000000..628510e06 --- /dev/null +++ b/src/agentos/skills/hub/capminal.py @@ -0,0 +1,229 @@ +"""Capminal skill source — browses and installs skills from Capminal. + +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 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, _parse_identifier +from agentos.skills.hub.source import SkillBundle, SkillMeta, SkillSource + +log = structlog.get_logger(__name__) + +_DEFAULT_REPO = "Capminal/agent-skills" +_DEFAULT_REF = "main" +# 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 +_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/agent-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" + + 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]: + 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.""" + 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 str(catalog.get("description") 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" + ) + + 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=identifier, + homepage=identifier, + 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 a743985a5..f7397f2be 100644 --- a/src/agentos/skills/hub/defaults.py +++ b/src/agentos/skills/hub/defaults.py @@ -6,6 +6,7 @@ from pathlib import Path 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 @@ -28,6 +29,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/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_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) diff --git a/tests/test_skills_hub_capminal.py b/tests/test_skills_hub_capminal.py new file mode 100644 index 000000000..df2d3ab71 --- /dev/null +++ b/tests/test_skills_hub_capminal.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +import json +from typing import Any + +import pytest + +from agentos.skills.hub.capminal import CapminalSource + +_FIXTURE_SLUGS = ("capminal", "contract-interaction", "morse-launch-b20", "broken") + + +def _meta( + slug: str, + *, + owner: str = "AndreaPN", + display_name: str | None = None, + version: str = "0.37.0", +) -> bytes: + return json.dumps( + { + "owner": owner, + "package": slug, + "displayName": display_name or slug.capitalize(), + "latestRelease": { + "version": version, + "publishedAt": 1748822400000, + }, + } + ).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/agent-skills _meta.json + SKILL.md fetches.""" + + metas = { + "capminal": _meta("capminal"), + "contract-interaction": _meta("contract-interaction"), + "morse-launch-b20": _meta("morse-launch-b20"), + "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" + ), + "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 + 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/agent-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, 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) + + +@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/agent-skills/tree/main/capminal" + assert capminal.emoji == "🤖" + assert capminal.tags == ["capminal", "crypto", "wallet"] + + +@pytest.mark.asyncio +async def test_inspect_and_fetch_enforce_allowlist(monkeypatch) -> None: + src = _source() + inspected_id = None + fetched_id = None + + async def mock_inspect(self_source, identifier): + nonlocal inspected_id + inspected_id = identifier + return None + + async def mock_fetch(self_source, identifier): + 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) + + # 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 + 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" },