Skip to content

Commit e5f0d6d

Browse files
authored
feat: runtime skill discovery — REST API, MCP server, cache, ranking, Python client (#42)
* feat: runtime skill discovery — REST API, MCP server, cache, ranking, Python client (#42) Add runtime skill discovery capabilities enabling agents to autonomously search and retrieve skills on demand, inspired by skyll.app's approach of making skills queryable by intent rather than requiring pre-installation. Core infrastructure: - Pluggable cache backend with LRU + TTL (MemoryCache) - Multi-signal relevance ranker (content 40pts, query 30pts, popularity 15pts, refs 15pts) - Enhanced SKILL.md parser with frontmatter extraction and reference discovery - Runtime skill injector fetching SKILL.md from GitHub with caching REST API server (@skillkit/api): - Hono-based HTTP server on port 3737 - GET/POST /search with ranked results and filters - GET /skills/:source/:id, /trending, /categories, /health, /cache/stats - Sliding-window rate limiting (60 req/min/IP) MCP server (@skillkit/mcp): - 4 tools: search_skills, get_skill, recommend_skills, list_categories - 2 resources: skills://trending, skills://categories - Compatible with Claude Desktop, Cursor, any MCP client CLI serve command: - `skillkit serve` starts the API server - Options: --port, --host, --cors, --cache-ttl Community registry: - registry/SKILLS.md with 26 curated skills across 12 categories - CommunityRegistry class integrated into FederatedSearch Python client (skillkit-client): - Async httpx + pydantic client for the REST API - search, get_skill, trending, categories, cache_stats, health All 856+ tests pass across 24 workspace tasks. * fix: resolve TypeScript errors in API package Add explicit return type to rate limiter middleware and remove stale CategoryCount type annotation in categories route. * fix: address CodeRabbit review findings and version-check CI - Fix version-check: bump docs packages to 1.11.0 - Parse first IP from X-Forwarded-For header in rate limiter - Add try/catch for malformed JSON in POST /search - Use exact source match in skill routes (owner/repo/id pattern) - Fix NaN fallback for invalid limit params in trending/search - Filter empty strings from query words in relevance ranker - Use Map lookups instead of O(n^2) find in MCP resources/tools - URL-encode path params in Python client - Fix -h flag conflict with Clipanion help in serve command - Fix typo: remoti-dev -> remotion-dev in registry SKILLS.md * fix: address Devin review — composite key lookups and Python URL path - Use source+name composite key Map lookups in search GET/POST routes - Use source+name composite key Map lookup in trending route - Fix Python client get_skill to use /skills/:owner/:repo/:id URL pattern - Update Python test to match new 3-segment skill URL
1 parent e569046 commit e5f0d6d

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

57 files changed

+2873
-2
lines changed

apps/skillkit/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"node": ">=18.0.0"
5454
},
5555
"dependencies": {
56+
"@skillkit/api": "workspace:*",
5657
"@skillkit/core": "workspace:*",
5758
"@skillkit/agents": "workspace:*",
5859
"@skillkit/cli": "workspace:*",

apps/skillkit/src/cli.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ import {
102102
SkillMdValidateCommand,
103103
SkillMdInitCommand,
104104
SkillMdCheckCommand,
105+
ServeCommand,
105106
} from '@skillkit/cli';
106107

107108
const __filename = fileURLToPath(import.meta.url);
@@ -229,4 +230,6 @@ cli.register(SkillMdValidateCommand);
229230
cli.register(SkillMdInitCommand);
230231
cli.register(SkillMdCheckCommand);
231232

233+
cli.register(ServeCommand);
234+
232235
cli.runExit(process.argv.slice(2));

clients/python/pyproject.toml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
[build-system]
2+
requires = ["hatchling"]
3+
build-backend = "hatchling.build"
4+
5+
[project]
6+
name = "skillkit-client"
7+
version = "0.1.0"
8+
description = "Python client for the SkillKit REST API"
9+
readme = "README.md"
10+
license = "Apache-2.0"
11+
requires-python = ">=3.9"
12+
authors = [
13+
{ name = "Rohit Ghumare", email = "ghumare64@gmail.com" },
14+
]
15+
keywords = ["skillkit", "ai", "agents", "skills", "discovery"]
16+
classifiers = [
17+
"Development Status :: 3 - Alpha",
18+
"Intended Audience :: Developers",
19+
"License :: OSI Approved :: Apache Software License",
20+
"Programming Language :: Python :: 3",
21+
"Programming Language :: Python :: 3.9",
22+
"Programming Language :: Python :: 3.10",
23+
"Programming Language :: Python :: 3.11",
24+
"Programming Language :: Python :: 3.12",
25+
"Topic :: Software Development :: Libraries",
26+
]
27+
dependencies = [
28+
"httpx>=0.27",
29+
"pydantic>=2.0",
30+
]
31+
32+
[project.optional-dependencies]
33+
dev = [
34+
"pytest>=7.0",
35+
"pytest-asyncio>=0.23",
36+
"respx>=0.21",
37+
]
38+
39+
[project.urls]
40+
Homepage = "https://github.com/rohitg00/skillkit"
41+
Documentation = "https://agenstskills.com"
42+
Repository = "https://github.com/rohitg00/skillkit"
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from skillkit.client import SkillKitClient
2+
from skillkit.models import Skill, SearchResponse, HealthResponse, CacheStats, Category
3+
4+
__all__ = [
5+
"SkillKitClient",
6+
"Skill",
7+
"SearchResponse",
8+
"HealthResponse",
9+
"CacheStats",
10+
"Category",
11+
]
12+
__version__ = "0.1.0"

clients/python/skillkit/client.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
from __future__ import annotations
2+
3+
from typing import Optional
4+
from urllib.parse import quote
5+
6+
import httpx
7+
8+
from skillkit.models import (
9+
CacheStats,
10+
CategoriesResponse,
11+
Category,
12+
HealthResponse,
13+
SearchResponse,
14+
Skill,
15+
TrendingResponse,
16+
)
17+
18+
19+
class SkillKitClient:
20+
def __init__(self, base_url: str = "http://localhost:3737", timeout: float = 30.0):
21+
self.base_url = base_url.rstrip("/")
22+
self.timeout = timeout
23+
self._client: Optional[httpx.AsyncClient] = None
24+
25+
async def __aenter__(self) -> "SkillKitClient":
26+
self._client = httpx.AsyncClient(base_url=self.base_url, timeout=self.timeout)
27+
return self
28+
29+
async def __aexit__(self, *args: object) -> None:
30+
if self._client:
31+
await self._client.aclose()
32+
self._client = None
33+
34+
def _get_client(self) -> httpx.AsyncClient:
35+
if self._client is None:
36+
self._client = httpx.AsyncClient(base_url=self.base_url, timeout=self.timeout)
37+
return self._client
38+
39+
async def health(self) -> HealthResponse:
40+
client = self._get_client()
41+
response = await client.get("/health")
42+
response.raise_for_status()
43+
data = response.json()
44+
return HealthResponse(
45+
status=data["status"],
46+
version=data["version"],
47+
skill_count=data.get("skillCount", 0),
48+
uptime=data.get("uptime", 0),
49+
)
50+
51+
async def search(
52+
self,
53+
query: str,
54+
limit: int = 20,
55+
include_content: bool = False,
56+
) -> SearchResponse:
57+
client = self._get_client()
58+
params = {"q": query, "limit": str(limit)}
59+
if include_content:
60+
params["include_content"] = "true"
61+
response = await client.get("/search", params=params)
62+
response.raise_for_status()
63+
data = response.json()
64+
return SearchResponse(
65+
skills=[Skill(**s) for s in data["skills"]],
66+
total=data["total"],
67+
query=data["query"],
68+
limit=data["limit"],
69+
)
70+
71+
async def search_with_filters(
72+
self,
73+
query: str,
74+
limit: int = 20,
75+
include_content: bool = False,
76+
tags: Optional[list[str]] = None,
77+
category: Optional[str] = None,
78+
source: Optional[str] = None,
79+
) -> SearchResponse:
80+
client = self._get_client()
81+
body: dict = {"query": query, "limit": limit, "include_content": include_content}
82+
filters: dict = {}
83+
if tags:
84+
filters["tags"] = tags
85+
if category:
86+
filters["category"] = category
87+
if source:
88+
filters["source"] = source
89+
if filters:
90+
body["filters"] = filters
91+
response = await client.post("/search", json=body)
92+
response.raise_for_status()
93+
data = response.json()
94+
return SearchResponse(
95+
skills=[Skill(**s) for s in data["skills"]],
96+
total=data["total"],
97+
query=data["query"],
98+
limit=data["limit"],
99+
)
100+
101+
async def get_skill(self, source: str, skill_id: str) -> Skill:
102+
client = self._get_client()
103+
parts = source.split("/", 1)
104+
owner = quote(parts[0], safe="")
105+
repo = quote(parts[1], safe="") if len(parts) > 1 else owner
106+
response = await client.get(f"/skills/{owner}/{repo}/{quote(skill_id, safe='')}")
107+
response.raise_for_status()
108+
return Skill(**response.json())
109+
110+
async def trending(self, limit: int = 20) -> list[Skill]:
111+
client = self._get_client()
112+
response = await client.get("/trending", params={"limit": str(limit)})
113+
response.raise_for_status()
114+
data = response.json()
115+
return [Skill(**s) for s in data["skills"]]
116+
117+
async def categories(self) -> list[Category]:
118+
client = self._get_client()
119+
response = await client.get("/categories")
120+
response.raise_for_status()
121+
data = response.json()
122+
return [Category(**c) for c in data["categories"]]
123+
124+
async def cache_stats(self) -> CacheStats:
125+
client = self._get_client()
126+
response = await client.get("/cache/stats")
127+
response.raise_for_status()
128+
data = response.json()
129+
return CacheStats(
130+
hits=data["hits"],
131+
misses=data["misses"],
132+
size=data["size"],
133+
max_size=data.get("maxSize", 0),
134+
hit_rate=data.get("hitRate", 0.0),
135+
)
136+
137+
async def close(self) -> None:
138+
if self._client:
139+
await self._client.aclose()
140+
self._client = None

clients/python/skillkit/models.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
from typing import Optional
2+
from pydantic import BaseModel
3+
4+
5+
class Skill(BaseModel):
6+
name: str
7+
description: Optional[str] = None
8+
source: str
9+
repo: Optional[str] = None
10+
tags: Optional[list[str]] = None
11+
category: Optional[str] = None
12+
content: Optional[str] = None
13+
stars: Optional[int] = None
14+
installs: Optional[int] = None
15+
16+
17+
class SearchResponse(BaseModel):
18+
skills: list[Skill]
19+
total: int
20+
query: str
21+
limit: int
22+
23+
24+
class HealthResponse(BaseModel):
25+
status: str
26+
version: str
27+
skill_count: int = 0
28+
uptime: int = 0
29+
30+
31+
class CacheStats(BaseModel):
32+
hits: int
33+
misses: int
34+
size: int
35+
max_size: int = 0
36+
hit_rate: float = 0.0
37+
38+
39+
class Category(BaseModel):
40+
name: str
41+
count: int
42+
43+
44+
class CategoriesResponse(BaseModel):
45+
categories: list[Category]
46+
total: int
47+
48+
49+
class TrendingResponse(BaseModel):
50+
skills: list[Skill]
51+
limit: int

clients/python/tests/__init__.py

Whitespace-only changes.
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import pytest
2+
import httpx
3+
import respx
4+
5+
from skillkit import SkillKitClient
6+
7+
8+
BASE_URL = "http://localhost:3737"
9+
10+
11+
@pytest.fixture
12+
def mock_api():
13+
with respx.mock(base_url=BASE_URL) as respx_mock:
14+
yield respx_mock
15+
16+
17+
@pytest.mark.asyncio
18+
async def test_health(mock_api):
19+
mock_api.get("/health").respond(
20+
json={"status": "ok", "version": "1.11.0", "skillCount": 100, "uptime": 60}
21+
)
22+
async with SkillKitClient(BASE_URL) as client:
23+
health = await client.health()
24+
assert health.status == "ok"
25+
assert health.version == "1.11.0"
26+
assert health.skill_count == 100
27+
28+
29+
@pytest.mark.asyncio
30+
async def test_search(mock_api):
31+
mock_api.get("/search").respond(
32+
json={
33+
"skills": [{"name": "react-perf", "source": "owner/repo"}],
34+
"total": 1,
35+
"query": "react",
36+
"limit": 20,
37+
}
38+
)
39+
async with SkillKitClient(BASE_URL) as client:
40+
result = await client.search("react")
41+
assert result.total == 1
42+
assert result.skills[0].name == "react-perf"
43+
44+
45+
@pytest.mark.asyncio
46+
async def test_search_with_filters(mock_api):
47+
mock_api.post("/search").respond(
48+
json={
49+
"skills": [{"name": "nextjs-auth", "source": "other/repo"}],
50+
"total": 1,
51+
"query": "auth",
52+
"limit": 20,
53+
}
54+
)
55+
async with SkillKitClient(BASE_URL) as client:
56+
result = await client.search_with_filters("auth", tags=["nextjs"])
57+
assert result.total == 1
58+
59+
60+
@pytest.mark.asyncio
61+
async def test_get_skill(mock_api):
62+
mock_api.get("/skills/owner/repo/react-perf").respond(
63+
json={"name": "react-perf", "description": "React performance", "source": "owner/repo"}
64+
)
65+
async with SkillKitClient(BASE_URL) as client:
66+
skill = await client.get_skill("owner/repo", "react-perf")
67+
assert skill.name == "react-perf"
68+
69+
70+
@pytest.mark.asyncio
71+
async def test_trending(mock_api):
72+
mock_api.get("/trending").respond(
73+
json={"skills": [{"name": "a", "source": "x/y"}], "limit": 20}
74+
)
75+
async with SkillKitClient(BASE_URL) as client:
76+
trending = await client.trending()
77+
assert len(trending) == 1
78+
79+
80+
@pytest.mark.asyncio
81+
async def test_categories(mock_api):
82+
mock_api.get("/categories").respond(
83+
json={"categories": [{"name": "react", "count": 5}], "total": 1}
84+
)
85+
async with SkillKitClient(BASE_URL) as client:
86+
cats = await client.categories()
87+
assert len(cats) == 1
88+
assert cats[0].name == "react"
89+
90+
91+
@pytest.mark.asyncio
92+
async def test_cache_stats(mock_api):
93+
mock_api.get("/cache/stats").respond(
94+
json={"hits": 10, "misses": 5, "size": 8, "maxSize": 500, "hitRate": 0.667}
95+
)
96+
async with SkillKitClient(BASE_URL) as client:
97+
stats = await client.cache_stats()
98+
assert stats.hits == 10
99+
assert stats.size == 8

docs/fumadocs/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "skillkit-docs",
3-
"version": "1.10.0",
3+
"version": "1.11.0",
44
"private": true,
55
"scripts": {
66
"build": "next build",

docs/skillkit/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "skillkit",
33
"private": true,
4-
"version": "1.10.0",
4+
"version": "1.11.0",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

0 commit comments

Comments
 (0)