feat: skill discovery — search and install skills from external sources (#111)

* feat: skill discovery — search and install skills from external sources

Add discovery UI and API for finding and installing skills from
skills.sh registries and GitHub repositories with one-click install,
SKILL.md frontmatter parsing, and security scan integration.

Core modules:
- skill_parser.py: ParsedSkill dataclass, parse_skill_md() with YAML
  frontmatter support (Anthropic + Hermes tag formats), name validation
- skill_sources.py: SkillsShClient (async search + resolve),
  fetch_skill_from_github (SKILL.md + bundled resource fetching with
  256KB cap, text extension filter, GitHub API tree traversal)

API:
- GET /v1/api/admin/skills/discover — search with installed annotation
  and scan_status for installed skills
- POST /v1/api/admin/skills/install — fetch, parse, duplicate check,
  create with origin="source" readonly=true, store resources, audit

Also fixes pre-existing bug where _skill_to_response omitted scan_status,
scan_report, scan_version fields — scan tier badges in the installed
skills table were silently empty despite data existing in storage.

Admin UI: pill toggle (Installed/Discover), discovery cards with scan
tier badges, GitHub import modal with proper focus trap/Escape/backdrop,
scoped selectors preventing MCP↔Skills cross-tab state corruption.

SDK: discover_skills() + install_skill() on Python (async+sync) and
TypeScript console clients.

48 new tests across 3 test files. All 2632 tests pass.

* fix: address copilot review — 404 vs 502, O(n) lookups, branch fallback

- SkillNotFoundError subclass: install returns 404 when SKILL.md is
  missing, 502 only for connectivity/upstream errors
- get_skill_by_source_url() + list_installed_skill_urls(): indexed
  storage lookups replace O(n) full-table scans with content blobs
- Default branch fallback: tries main then master when URL doesn't
  specify a branch
- Path normalization: strip trailing slash once, remove redundant
  candidate
- SDK install_skill() returns typed SkillInfo with response_model
- Tree size guard: skip resource tree if response >2MB
This commit is contained in:
Patrick Buckley
2026-03-16 18:42:32 -07:00
committed by GitHub
parent e71ea38953
commit c28bfc1e58
29 changed files with 2601 additions and 12 deletions
+1 -1
View File
@@ -145,7 +145,7 @@ Turnstone includes a built-in governance layer for enterprise deployments — ma
- **RBAC** — 15 granular permissions, 3 built-in roles (admin / operator / viewer), custom roles, privilege escalation prevention
- **OIDC SSO** — single sign-on via any OpenID Connect provider (Okta, Azure AD, Google, Keycloak); Authorization Code Flow with PKCE, auto-provisioning, claim-based role mapping with demotion propagation; see [docs/oidc.md](docs/oidc.md)
- **Tool policies** — glob-pattern rules (`allow` / `deny` / `ask`) with priority ordering; automate approvals or lock down dangerous tools
- **Skills** — reusable behavioral profiles with system prompts, `{{variable}}` substitution, session config (model, temperature, token budget), install-time security scanning, and version history
- **Skills** — reusable behavioral profiles with system prompts, `{{variable}}` substitution, session config (model, temperature, token budget), install-time security scanning, version history, and external discovery (search and install from skills.sh or GitHub repositories)
- **Usage tracking** — per-request token and tool metrics, aggregation by day / model / user, automatic 90-day pruning
- **Audit logging** — append-only event trail for all admin mutations, IP-aware, 365-day retention
+68
View File
@@ -1350,6 +1350,74 @@ version. Requires the `admin.skills` permission.
---
### `GET /v1/api/admin/skills/discover` (Console)
Search external skill registries for available skills. Requires the
`admin.skills` permission.
**Query parameters:**
| Parameter | Type | Default | Description |
|-----------|--------|---------|-------------|
| `q` | string | `""` | Search query |
| `limit` | int | `20` | Max results (1100) |
**Response:**
```json
{
"skills": [
{
"id": "owner/repo/skill-name",
"name": "skill-name",
"description": "A skill description",
"author": "Author Name",
"source": "skills.sh",
"source_url": "https://github.com/owner/repo",
"install_count": 42,
"tags": ["coding", "review"],
"installed": false
}
]
}
```
**Error:** `502` if the registry is unreachable.
---
### `POST /v1/api/admin/skills/install` (Console)
Install a skill from an external source (skills.sh registry or GitHub).
Requires the `admin.skills` permission.
**Request body:**
```json
{
"source": "github",
"url": "https://github.com/owner/skill-repo"
}
```
Or for skills.sh:
```json
{
"source": "skills.sh",
"skill_id": "owner/skill-name"
}
```
**Response:** Same as `GET /v1/api/admin/skills/{skill_id}` — the created
skill object.
**Errors:** `400` invalid source or missing fields, `404` SKILL.md not found,
`409` skill already installed (duplicate source_url or name), `502` source
unreachable.
---
### `GET /v1/api/admin/settings` (Console)
List all settings with their effective values, defaults, and metadata. Requires
@@ -0,0 +1,88 @@
@startuml
!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Component.puml
LAYOUT_LEFT_RIGHT()
title Skills Discovery Architecture
skinparam backgroundColor #1e1e2e
skinparam defaultFontColor #cdd6f4
skinparam defaultFontName "JetBrains Mono"
skinparam arrowColor #89b4fa
skinparam rectangleBorderColor #585b70
skinparam rectangleBackgroundColor #313244
skinparam noteBorderColor #585b70
skinparam noteBackgroundColor #45475a
skinparam packageBorderColor #585b70
package "External Sources" as ext #181825 {
rectangle "skills.sh\nRegistry" as skillssh
rectangle "GitHub\nRepositories" as github
}
package "Console Server" as console #181825 {
rectangle "admin_skill_discover\nGET /v1/api/admin/skills/discover" as discover
rectangle "admin_skill_install\nPOST /v1/api/admin/skills/install" as install
rectangle "_get_discovery_url\nsettings fallback" as settings
}
package "Core Modules" as core #181825 {
rectangle "SkillsShClient\nskill_sources.py" as client
rectangle "fetch_skill_from_github\nskill_sources.py" as fetcher
rectangle "parse_skill_md\nskill_parser.py" as parser
rectangle "scan_skill_content\nstorage/_utils.py" as scanner
}
package "Storage" as storage #181825 {
rectangle "prompt_templates\n(skills)" as skills_table
rectangle "skill_resources\n(bundled files)" as resources_table
rectangle "system_settings\n(discovery_url)" as settings_table
}
package "Admin UI" as ui #181825 {
rectangle "Skills Tab\nInstalled / Discover pill" as pill
rectangle "Discovery View\nsearch + cards" as discoverui
rectangle "GitHub Import\nmodal" as importui
}
' External flow
discover --> settings : resolve URL
settings --> settings_table : DB → config → default
discover --> client : search(query)
client --> skillssh : GET /api/search
install --> client : resolve_github_url()
client --> skillssh : GET /api/skills/{id}
install --> fetcher : fetch SKILL.md + resources
fetcher --> github : raw.githubusercontent.com
fetcher --> github : api.github.com/git/trees
fetcher --> parser : parse frontmatter
install --> scanner : auto-scan on create
install --> skills_table : create_prompt_template
install --> resources_table : create_skill_resource
' UI flow
pill --> discoverui : switch view
discoverui --> discover : authFetch()
importui --> install : POST (github source)
' Annotations
note right of parser
YAML frontmatter → ParsedSkill
Anthropic + Hermes tag formats
Name validation (lowercase+hyphens)
end note
note right of fetcher
Tries: direct path, root,
monorepo skills/{name}/
256KB SKILL.md cap
Text extensions filter for resources
end note
note right of scanner
4 risk axes (content, supply chain,
vulnerability, capability)
Auto-triggers on create/update
end note
@enduml
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:106138c450686bc66c6d794102501feec628968eb647535a3d03d2c93d9964b2
size 160650
+10
View File
@@ -86,6 +86,16 @@ etc.) since workstream templates were merged into the skills system in v0.8.0.
capability risk (from `allowed_tools`). Results populate the `scan_status`
(safe/low/medium/high/critical) and `scan_report` (JSON breakdown) columns.
These fields are system-managed and cannot be overwritten via the admin API.
- **Discovery**: External skills can be discovered and installed from registries:
- `GET /v1/api/admin/skills/discover?q=...` — search the skills.sh registry
(or a custom registry via `skills.discovery_url` setting)
- `POST /v1/api/admin/skills/install` — install from skills.sh or GitHub.
Fetches the `SKILL.md` file, parses YAML frontmatter, creates a skill with
`origin="source"` and `readonly=True`, stores bundled resources.
- Admin UI: Skills tab has "Installed" / "Discover" pill toggle.
Discovery view has search bar, result cards, and "Import from GitHub" modal.
- SDK: `discover_skills(q)` and `install_skill(source, skill_id=..., url=...)`
on both Python and TypeScript console clients.
### Usage Tracking
+9
View File
@@ -106,6 +106,8 @@ Both `TurnstoneConsole` (sync) and `AsyncTurnstoneConsole` (async) expose:
| | `list_schedule_runs(task_id, *, limit=50)` | `ListScheduleRunsResponse` |
| **MCP Registry** | `search_mcp_registry(q="", *, limit=20, cursor=None)` | `RegistrySearchResponse` |
| | `install_from_registry(registry_name, source, *, index=0, name="", variables=None, env=None, headers=None)` | `McpServerDetail` |
| **Skill Discovery** | `discover_skills(q="", *, limit=20)` | `SkillDiscoverResponse` |
| | `install_skill(source, *, skill_id="", url="")` | `dict` |
| **Streaming** | `stream_cluster_events()` | `Iterator[ClusterEvent]` |
| **Auth** | `login(username=..., password=...)` / `login(token="ts_xxx")` | `AuthLoginResponse` |
| | `logout()` | `StatusResponse` |
@@ -231,6 +233,13 @@ const server = await client.installFromRegistry({
source: "remote",
});
// Search and install skills from external registries
const skills = await client.discoverSkills({ q: "code review" });
const skill = await client.installSkill({
source: "github",
url: "https://github.com/owner/skill-repo",
});
// Stream cluster events
for await (const event of client.clusterEvents()) {
console.log(event.type, event);
+5
View File
@@ -35,6 +35,7 @@ dependencies = [
"structlog>=24.1",
"PyJWT>=2.8",
"bcrypt>=4.0",
"python-frontmatter>=1.0",
]
[project.urls]
@@ -159,6 +160,10 @@ ignore_missing_imports = true
module = ["croniter", "croniter.*"]
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = ["frontmatter", "frontmatter.*"]
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = ["turnstone.channels.discord.*"]
disallow_subclassing_any = false
+241
View File
@@ -1768,6 +1768,128 @@
}
}
},
"/v1/api/admin/skills/discover": {
"get": {
"summary": "Search external skill registries for available skills",
"operationId": "v1_api_admin_skills_discover_get",
"tags": [
"Admin"
],
"parameters": [
{
"name": "q",
"in": "query",
"required": false,
"schema": {
"type": "string"
},
"description": "Search query"
},
{
"name": "limit",
"in": "query",
"required": false,
"schema": {
"type": "integer"
},
"description": "Max results (default 20, max 100)"
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SkillDiscoverResponse"
}
}
}
},
"502": {
"description": "Error 502",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/v1/api/admin/skills/install": {
"post": {
"summary": "Install a skill from an external source",
"operationId": "v1_api_admin_skills_install_post",
"tags": [
"Admin"
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SkillInstallRequest"
}
}
}
},
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SkillInfo"
}
}
}
},
"400": {
"description": "Error 400",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"404": {
"description": "Error 404",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"409": {
"description": "Error 409",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"502": {
"description": "Error 502",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
}
}
}
},
"/v1/api/admin/skills": {
"get": {
"summary": "List skills",
@@ -6280,6 +6402,110 @@
"title": "RegistryInstallRequest",
"type": "object"
},
"SkillDiscoverResponse": {
"properties": {
"skills": {
"items": {
"$ref": "#/components/schemas/SkillDiscoverListing"
},
"title": "Skills",
"type": "array"
}
},
"required": [
"skills"
],
"title": "SkillDiscoverResponse",
"type": "object"
},
"SkillDiscoverListing": {
"properties": {
"id": {
"title": "Id",
"type": "string"
},
"name": {
"title": "Name",
"type": "string"
},
"description": {
"default": "",
"title": "Description",
"type": "string"
},
"author": {
"default": "",
"title": "Author",
"type": "string"
},
"source": {
"default": "",
"title": "Source",
"type": "string"
},
"source_url": {
"default": "",
"title": "Source Url",
"type": "string"
},
"install_count": {
"default": 0,
"title": "Install Count",
"type": "integer"
},
"tags": {
"items": {
"type": "string"
},
"title": "Tags",
"type": "array"
},
"installed": {
"default": false,
"title": "Installed",
"type": "boolean"
},
"scan_status": {
"default": "",
"title": "Scan Status",
"type": "string"
},
"template_id": {
"default": "",
"title": "Template Id",
"type": "string"
}
},
"required": [
"id",
"name"
],
"title": "SkillDiscoverListing",
"type": "object"
},
"SkillInstallRequest": {
"properties": {
"source": {
"title": "Source",
"type": "string"
},
"skill_id": {
"default": "",
"title": "Skill Id",
"type": "string"
},
"url": {
"default": "",
"title": "Url",
"type": "string"
}
},
"required": [
"source"
],
"title": "SkillInstallRequest",
"type": "object"
},
"SkillInfo": {
"properties": {
"template_id": {
@@ -6439,6 +6665,21 @@
"title": "Allowed Tools",
"type": "string"
},
"scan_status": {
"default": "",
"title": "Scan Status",
"type": "string"
},
"scan_report": {
"default": "{}",
"title": "Scan Report",
"type": "string"
},
"scan_version": {
"default": "",
"title": "Scan Version",
"type": "string"
},
"created": {
"title": "Created",
"type": "string"
+23 -1
View File
@@ -32,13 +32,15 @@ import type {
McpServerDetail,
RegistryInstallRequest,
RegistrySearchResponse,
SkillDiscoverResponse,
SkillInfo,
SkillInstallRequest,
NodeDetailResponse,
NodesOptions,
OrgInfo,
RoleInfo,
ScheduleInfo,
SettingInfo,
SkillInfo,
StatusResponse,
ToolPolicyInfo,
UpdateMcpServerRequest,
@@ -440,4 +442,24 @@ export class TurnstoneConsole extends BaseClient {
json: body,
});
}
// -- Skill Discovery ------------------------------------------------------
async discoverSkills(opts?: {
q?: string;
limit?: number;
}): Promise<SkillDiscoverResponse> {
const params: Record<string, string> = {};
if (opts?.q) params.q = opts.q;
if (opts?.limit) params.limit = String(opts.limit);
return this.request("GET", "/v1/api/admin/skills/discover", {
params,
});
}
async installSkill(body: SkillInstallRequest): Promise<SkillInfo> {
return this.request("POST", "/v1/api/admin/skills/install", {
json: body,
});
}
}
+4
View File
@@ -171,6 +171,10 @@ export type {
RegistryServerInfo,
RegistrySearchResponse,
RegistryInstallRequest,
// Skill discovery types
SkillDiscoverListing,
SkillDiscoverResponse,
SkillInstallRequest,
} from "./types.js";
// SSE parser (for advanced usage)
+26
View File
@@ -831,6 +831,32 @@ export interface RegistryInstallRequest {
headers?: Record<string, string>;
}
// -- Console API: Skill Discovery -------------------------------------------
export interface SkillDiscoverListing {
id: string;
name: string;
description: string;
author: string;
source: string;
source_url: string;
install_count: number;
tags: string[];
installed: boolean;
scan_status?: string;
template_id?: string;
}
export interface SkillDiscoverResponse {
skills: SkillDiscoverListing[];
}
export interface SkillInstallRequest {
source: string;
skill_id?: string;
url?: string;
}
// -- Console API: System Settings -------------------------------------------
export interface SettingInfo {
+397
View File
@@ -0,0 +1,397 @@
"""Tests for skill discovery admin API endpoints."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock, patch
import pytest
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.routing import Mount, Route
from starlette.testclient import TestClient
if TYPE_CHECKING:
from starlette.requests import Request
from starlette.responses import Response
from turnstone.console.server import admin_skill_discover, admin_skill_install
from turnstone.core.auth import AuthResult
from turnstone.core.skill_parser import ParsedSkill
from turnstone.core.skill_sources import (
SkillListing,
SkillNotFoundError,
SkillPackage,
SkillSourceError,
)
from turnstone.core.storage._sqlite import SQLiteBackend
# ---------------------------------------------------------------------------
# Auth middleware
# ---------------------------------------------------------------------------
class _InjectAuthMiddleware(BaseHTTPMiddleware):
"""Inject an admin auth result with admin.skills permission."""
async def dispatch(self, request: Request, call_next: Any) -> Response:
request.state.auth_result = AuthResult(
user_id="test-user",
scopes=frozenset({"approve"}),
token_source="config",
permissions=frozenset({"read", "write", "approve", "admin.skills"}),
)
return await call_next(request)
class _InjectAuthNoSkillsMiddleware(BaseHTTPMiddleware):
"""Inject an auth result WITHOUT admin.skills permission."""
async def dispatch(self, request: Request, call_next: Any) -> Response:
request.state.auth_result = AuthResult(
user_id="test-user",
scopes=frozenset({"approve"}),
token_source="jwt",
permissions=frozenset({"read", "write", "approve"}),
)
return await call_next(request)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
_ROUTES = [
Mount(
"/v1",
routes=[
Route("/api/admin/skills/discover", admin_skill_discover),
Route(
"/api/admin/skills/install",
admin_skill_install,
methods=["POST"],
),
],
),
]
@pytest.fixture
def storage(tmp_path):
return SQLiteBackend(str(tmp_path / "test.db"))
@pytest.fixture
def client(storage):
app = Starlette(
routes=_ROUTES,
middleware=[Middleware(_InjectAuthMiddleware)],
)
app.state.auth_storage = storage
return TestClient(app)
@pytest.fixture
def client_no_perm(storage):
app = Starlette(
routes=_ROUTES,
middleware=[Middleware(_InjectAuthNoSkillsMiddleware)],
)
app.state.auth_storage = storage
return TestClient(app)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _sample_listing(
name: str = "test-skill",
skill_id: str = "owner/repo/test-skill",
) -> SkillListing:
return SkillListing(
id=skill_id,
name=name,
description="A test skill",
author="Test Author",
source="skills.sh",
source_url="https://github.com/owner/repo",
install_count=42,
tags=["test"],
)
def _sample_package(
name: str = "test-skill",
source_url: str = "https://github.com/owner/repo",
) -> SkillPackage:
return SkillPackage(
listing=SkillListing(
id=f"owner/repo/{name}",
name=name,
description="A test skill",
author="Test Author",
source="github",
source_url=source_url,
tags=["test"],
),
parsed=ParsedSkill(
name=name,
description="A test skill",
content="# Test Skill\n\nInstructions here.",
tags=["test"],
author="Test Author",
version="1.0.0",
),
resources={"scripts/setup.sh": "#!/bin/bash\necho hello"},
)
# ---------------------------------------------------------------------------
# Tests: Discover
# ---------------------------------------------------------------------------
class TestSkillDiscover:
def test_search_basic(self, client: TestClient) -> None:
listings = [_sample_listing()]
with patch("turnstone.core.skill_sources.SkillsShClient") as mock_cls:
instance = mock_cls.return_value
instance.search = AsyncMock(return_value=listings)
resp = client.get("/v1/api/admin/skills/discover?q=test")
assert resp.status_code == 200
data = resp.json()
assert len(data["skills"]) == 1
assert data["skills"][0]["name"] == "test-skill"
assert data["skills"][0]["installed"] is False
def test_search_empty_results(self, client: TestClient) -> None:
with patch("turnstone.core.skill_sources.SkillsShClient") as mock_cls:
instance = mock_cls.return_value
instance.search = AsyncMock(return_value=[])
resp = client.get("/v1/api/admin/skills/discover")
assert resp.status_code == 200
assert resp.json()["skills"] == []
def test_search_permission_denied(self, client_no_perm: TestClient) -> None:
resp = client_no_perm.get("/v1/api/admin/skills/discover")
assert resp.status_code == 403
def test_search_marks_installed(self, client: TestClient, storage: SQLiteBackend) -> None:
# Pre-install a skill with matching source_url
storage.create_prompt_template(
template_id="existing-id",
name="test-skill",
category="general",
content="existing content",
variables="[]",
is_default=False,
org_id="",
created_by="admin",
source_url="https://github.com/owner/repo",
)
listings = [_sample_listing()]
with patch("turnstone.core.skill_sources.SkillsShClient") as mock_cls:
instance = mock_cls.return_value
instance.search = AsyncMock(return_value=listings)
resp = client.get("/v1/api/admin/skills/discover?q=test")
assert resp.status_code == 200
assert resp.json()["skills"][0]["installed"] is True
def test_search_source_error(self, client: TestClient) -> None:
with patch("turnstone.core.skill_sources.SkillsShClient") as mock_cls:
instance = mock_cls.return_value
instance.search = AsyncMock(side_effect=SkillSourceError("timeout"))
resp = client.get("/v1/api/admin/skills/discover?q=test")
assert resp.status_code == 502
assert "timeout" in resp.json()["error"]
# ---------------------------------------------------------------------------
# Tests: Install
# ---------------------------------------------------------------------------
class TestSkillInstall:
def test_install_from_github(self, client: TestClient) -> None:
package = _sample_package()
with patch(
"turnstone.core.skill_sources.fetch_skill_from_github", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = package
resp = client.post(
"/v1/api/admin/skills/install",
json={"source": "github", "url": "https://github.com/owner/repo"},
)
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "test-skill"
assert data["origin"] == "source"
assert data["readonly"] is True
assert data["source_url"] == "https://github.com/owner/repo"
def test_install_from_skills_sh(self, client: TestClient) -> None:
package = _sample_package()
with (
patch("turnstone.core.skill_sources.SkillsShClient") as mock_cls,
patch(
"turnstone.core.skill_sources.fetch_skill_from_github", new_callable=AsyncMock
) as mock_fetch,
):
instance = mock_cls.return_value
instance.resolve_github_url = AsyncMock(return_value="https://github.com/owner/repo")
mock_fetch.return_value = package
resp = client.post(
"/v1/api/admin/skills/install",
json={"source": "skills.sh", "skill_id": "owner/test-skill"},
)
assert resp.status_code == 200
assert resp.json()["name"] == "test-skill"
def test_install_invalid_source(self, client: TestClient) -> None:
resp = client.post(
"/v1/api/admin/skills/install",
json={"source": "invalid"},
)
assert resp.status_code == 400
def test_install_missing_url(self, client: TestClient) -> None:
resp = client.post(
"/v1/api/admin/skills/install",
json={"source": "github"},
)
assert resp.status_code == 400
def test_install_missing_skill_id(self, client: TestClient) -> None:
resp = client.post(
"/v1/api/admin/skills/install",
json={"source": "skills.sh"},
)
assert resp.status_code == 400
def test_install_duplicate_source_url(self, client: TestClient, storage: SQLiteBackend) -> None:
# Pre-install
storage.create_prompt_template(
template_id="existing-id",
name="existing-skill",
category="general",
content="content",
variables="[]",
is_default=False,
org_id="",
created_by="admin",
source_url="https://github.com/owner/repo",
)
package = _sample_package()
with patch(
"turnstone.core.skill_sources.fetch_skill_from_github", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = package
resp = client.post(
"/v1/api/admin/skills/install",
json={"source": "github", "url": "https://github.com/owner/repo"},
)
assert resp.status_code == 409
def test_install_duplicate_name(self, client: TestClient, storage: SQLiteBackend) -> None:
# Pre-install with same name but different source_url
storage.create_prompt_template(
template_id="existing-id",
name="test-skill",
category="general",
content="content",
variables="[]",
is_default=False,
org_id="",
created_by="admin",
source_url="https://github.com/other/repo",
)
package = _sample_package(source_url="https://github.com/owner/different-repo")
with patch(
"turnstone.core.skill_sources.fetch_skill_from_github", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = package
resp = client.post(
"/v1/api/admin/skills/install",
json={"source": "github", "url": "https://github.com/owner/different-repo"},
)
assert resp.status_code == 409
def test_install_not_found(self, client: TestClient) -> None:
with patch(
"turnstone.core.skill_sources.fetch_skill_from_github", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.side_effect = SkillNotFoundError("SKILL.md not found")
resp = client.post(
"/v1/api/admin/skills/install",
json={"source": "github", "url": "https://github.com/owner/repo"},
)
assert resp.status_code == 404
def test_install_source_error_returns_502(self, client: TestClient) -> None:
with patch(
"turnstone.core.skill_sources.fetch_skill_from_github", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.side_effect = SkillSourceError("connection timeout")
resp = client.post(
"/v1/api/admin/skills/install",
json={"source": "github", "url": "https://github.com/owner/repo"},
)
assert resp.status_code == 502
def test_install_permission_denied(self, client_no_perm: TestClient) -> None:
resp = client_no_perm.post(
"/v1/api/admin/skills/install",
json={"source": "github", "url": "https://github.com/owner/repo"},
)
assert resp.status_code == 403
def test_install_stores_resources(self, client: TestClient, storage: SQLiteBackend) -> None:
package = _sample_package()
with patch(
"turnstone.core.skill_sources.fetch_skill_from_github", new_callable=AsyncMock
) as mock_fetch:
mock_fetch.return_value = package
resp = client.post(
"/v1/api/admin/skills/install",
json={"source": "github", "url": "https://github.com/owner/repo"},
)
assert resp.status_code == 200
skill_id = resp.json()["template_id"]
resources = storage.list_skill_resources(skill_id)
assert len(resources) == 1
assert resources[0]["path"] == "scripts/setup.sh"
+249
View File
@@ -0,0 +1,249 @@
"""Tests for turnstone.core.skill_parser."""
from __future__ import annotations
import pytest
from turnstone.core.skill_parser import parse_skill_md, validate_skill_name
class TestParseSkillMd:
"""Parse valid SKILL.md with various field configurations."""
def test_full_frontmatter(self) -> None:
raw = """\
---
name: code-review
description: Automated code review skill
author: Test Author
version: 2.0.0
tags: [python, review, quality]
allowed_tools: [read_file, list_directory]
license: MIT
compatibility: ">=0.7"
---
# Code Review
Review code for best practices.
"""
result = parse_skill_md(raw)
assert result.name == "code-review"
assert result.description == "Automated code review skill"
assert result.author == "Test Author"
assert result.version == "2.0.0"
assert result.tags == ["python", "review", "quality"]
assert result.allowed_tools == ["read_file", "list_directory"]
assert result.license == "MIT"
assert result.compatibility == ">=0.7"
assert "# Code Review" in result.content
assert result.raw_frontmatter["name"] == "code-review"
def test_minimal_frontmatter(self) -> None:
raw = """\
---
name: minimal
---
Just some content.
"""
result = parse_skill_md(raw)
assert result.name == "minimal"
assert result.description == "Just some content."
assert result.version == "1.0.0"
assert result.tags == []
assert result.allowed_tools == []
def test_missing_name_raises(self) -> None:
raw = """\
---
description: No name field
---
Content here.
"""
with pytest.raises(ValueError, match="name is required"):
parse_skill_md(raw)
def test_name_too_long_raises(self) -> None:
raw = f"""\
---
name: {"a" * 65}
---
Content.
"""
with pytest.raises(ValueError, match="exceeds 64 characters"):
parse_skill_md(raw)
def test_name_invalid_chars_raises(self) -> None:
raw = """\
---
name: Invalid_Name!
---
Content.
"""
with pytest.raises(ValueError, match="lowercase alphanumeric"):
parse_skill_md(raw)
def test_single_char_name(self) -> None:
raw = """\
---
name: x
---
Content.
"""
result = parse_skill_md(raw)
assert result.name == "x"
def test_name_uppercased_normalized(self) -> None:
raw = """\
---
name: Code-Review
---
Content.
"""
result = parse_skill_md(raw)
assert result.name == "code-review"
def test_description_fallback_from_heading(self) -> None:
raw = """\
---
name: test-skill
---
# My Awesome Skill
More content here.
"""
result = parse_skill_md(raw)
assert result.description == "My Awesome Skill"
def test_description_fallback_from_text(self) -> None:
raw = """\
---
name: test-skill
---
This is the first line of content.
And more.
"""
result = parse_skill_md(raw)
assert result.description == "This is the first line of content."
def test_frozen_dataclass(self) -> None:
result = parse_skill_md("---\nname: frozen-test\n---\nContent.")
with pytest.raises(AttributeError):
result.name = "changed" # type: ignore[misc]
class TestHermesTags:
"""Handle Hermes-format tag nesting."""
def test_hermes_tags(self) -> None:
raw = """\
---
name: hermes-skill
metadata:
hermes:
tags: [ai, assistant]
---
Content.
"""
result = parse_skill_md(raw)
assert result.tags == ["ai", "assistant"]
def test_anthropic_tags(self) -> None:
raw = """\
---
name: anthropic-skill
metadata:
tags: [claude, coding]
---
Content.
"""
result = parse_skill_md(raw)
assert result.tags == ["claude", "coding"]
def test_direct_tags_take_precedence(self) -> None:
raw = """\
---
name: precedence
tags: [direct]
metadata:
tags: [nested]
---
Content.
"""
result = parse_skill_md(raw)
assert result.tags == ["direct"]
class TestAllowedTools:
"""Verify allowed_tools parsing."""
def test_list_format(self) -> None:
raw = """\
---
name: tools-list
allowed_tools: [bash, read_file]
---
Content.
"""
result = parse_skill_md(raw)
assert result.allowed_tools == ["bash", "read_file"]
def test_csv_format(self) -> None:
raw = """\
---
name: tools-csv
allowed_tools: "bash, read_file, write_file"
---
Content.
"""
result = parse_skill_md(raw)
assert result.allowed_tools == ["bash", "read_file", "write_file"]
def test_empty_allowed_tools(self) -> None:
raw = """\
---
name: no-tools
---
Content.
"""
result = parse_skill_md(raw)
assert result.allowed_tools == []
class TestValidateSkillName:
"""Name validation edge cases."""
def test_valid_names(self) -> None:
assert validate_skill_name("code-review") is None
assert validate_skill_name("a") is None
assert validate_skill_name("my-skill-123") is None
assert validate_skill_name("x" * 64) is None
def test_empty_name(self) -> None:
assert validate_skill_name("") == "name is required"
def test_too_long(self) -> None:
err = validate_skill_name("x" * 65)
assert err is not None
assert "64 characters" in err
def test_invalid_characters(self) -> None:
assert validate_skill_name("has_underscore") is not None
assert validate_skill_name("HAS-UPPER") is not None
assert validate_skill_name("has space") is not None
assert validate_skill_name("-leading-hyphen") is not None
+247
View File
@@ -0,0 +1,247 @@
"""Tests for turnstone.core.skill_sources."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from turnstone.core.skill_sources import (
SkillNotFoundError,
SkillSourceError,
SkillsShClient,
_parse_github_url,
fetch_skill_from_github,
)
class TestParseGitHubUrl:
"""GitHub URL parsing."""
def test_simple_repo(self) -> None:
owner, repo, branch, path = _parse_github_url("https://github.com/owner/repo")
assert owner == "owner"
assert repo == "repo"
assert branch == "main"
assert path == ""
def test_repo_with_branch(self) -> None:
owner, repo, branch, path = _parse_github_url("https://github.com/owner/repo/tree/develop")
assert branch == "develop"
assert path == ""
def test_repo_with_path(self) -> None:
owner, repo, branch, path = _parse_github_url(
"https://github.com/owner/repo/tree/main/skills/code-review"
)
assert owner == "owner"
assert repo == "repo"
assert branch == "main"
assert path == "skills/code-review"
def test_blob_url(self) -> None:
owner, repo, branch, path = _parse_github_url(
"https://github.com/owner/repo/blob/main/SKILL.md"
)
assert branch == "main"
assert path == "SKILL.md"
def test_invalid_url(self) -> None:
owner, repo, branch, path = _parse_github_url("https://gitlab.com/owner/repo")
assert owner == ""
class TestSkillsShClient:
"""SkillsShClient with mocked httpx."""
@pytest.mark.anyio
async def test_search_basic(self) -> None:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.raise_for_status = MagicMock()
mock_response.json.return_value = {
"skills": [
{
"id": "test/skill",
"name": "test-skill",
"description": "A test skill",
"author": "tester",
"source_url": "https://github.com/test/skill",
"install_count": 42,
"tags": ["test"],
}
]
}
with patch("turnstone.core.skill_sources.httpx.AsyncClient") as mock_client_cls:
instance = AsyncMock()
instance.get = AsyncMock(return_value=mock_response)
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
mock_client_cls.return_value = instance
client = SkillsShClient()
results = await client.search(query="test", limit=10)
assert len(results) == 1
assert results[0].name == "test-skill"
assert results[0].install_count == 42
assert results[0].source == "skills.sh"
@pytest.mark.anyio
async def test_search_empty(self) -> None:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.raise_for_status = MagicMock()
mock_response.json.return_value = {"skills": []}
with patch("turnstone.core.skill_sources.httpx.AsyncClient") as mock_client_cls:
instance = AsyncMock()
instance.get = AsyncMock(return_value=mock_response)
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
mock_client_cls.return_value = instance
client = SkillsShClient()
results = await client.search()
assert results == []
@pytest.mark.anyio
async def test_search_http_error(self) -> None:
with patch("turnstone.core.skill_sources.httpx.AsyncClient") as mock_client_cls:
instance = AsyncMock()
instance.get = AsyncMock(side_effect=httpx.ConnectTimeout("timeout"))
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
mock_client_cls.return_value = instance
client = SkillsShClient()
with pytest.raises(SkillSourceError, match="request failed"):
await client.search(query="test")
@pytest.mark.anyio
async def test_search_server_error(self) -> None:
mock_response = MagicMock()
mock_response.status_code = 500
mock_response.raise_for_status = MagicMock(
side_effect=httpx.HTTPStatusError("500", request=MagicMock(), response=mock_response)
)
with patch("turnstone.core.skill_sources.httpx.AsyncClient") as mock_client_cls:
instance = AsyncMock()
instance.get = AsyncMock(return_value=mock_response)
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
mock_client_cls.return_value = instance
client = SkillsShClient()
with pytest.raises(SkillSourceError, match="returned 500"):
await client.search()
@pytest.mark.anyio
async def test_custom_base_url(self) -> None:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.raise_for_status = MagicMock()
mock_response.json.return_value = {"skills": []}
with patch("turnstone.core.skill_sources.httpx.AsyncClient") as mock_client_cls:
instance = AsyncMock()
instance.get = AsyncMock(return_value=mock_response)
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
mock_client_cls.return_value = instance
client = SkillsShClient(base_url="https://custom.registry.io")
await client.search()
# Verify the URL uses the custom base
call_args = instance.get.call_args
assert "custom.registry.io" in str(call_args)
@pytest.mark.anyio
async def test_resolve_github_url(self) -> None:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.raise_for_status = MagicMock()
mock_response.json.return_value = {"source_url": "https://github.com/owner/skill-repo"}
with patch("turnstone.core.skill_sources.httpx.AsyncClient") as mock_client_cls:
instance = AsyncMock()
instance.get = AsyncMock(return_value=mock_response)
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
mock_client_cls.return_value = instance
client = SkillsShClient()
url = await client.resolve_github_url("owner/skill")
assert url == "https://github.com/owner/skill-repo"
class TestFetchSkillFromGithub:
"""GitHub fetch with mocked httpx."""
@pytest.mark.anyio
async def test_invalid_url(self) -> None:
with pytest.raises(SkillSourceError, match="Could not parse"):
await fetch_skill_from_github("https://gitlab.com/bad/url")
@pytest.mark.anyio
async def test_skill_md_not_found(self) -> None:
with patch("turnstone.core.skill_sources.httpx.AsyncClient") as mock_client_cls:
instance = AsyncMock()
not_found = MagicMock()
not_found.status_code = 404
instance.get = AsyncMock(return_value=not_found)
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
mock_client_cls.return_value = instance
with pytest.raises(SkillNotFoundError, match="SKILL.md not found"):
await fetch_skill_from_github("https://github.com/owner/repo")
@pytest.mark.anyio
async def test_fetch_success(self) -> None:
skill_content = """\
---
name: test-skill
description: A test skill
author: Test Author
tags: [test]
---
# Test Skill
Instructions here.
"""
tree_data = {"tree": []}
def mock_get(url, **kwargs):
resp = MagicMock()
if "raw.githubusercontent.com" in url and "SKILL.md" in url:
resp.status_code = 200
resp.text = skill_content
elif "api.github.com" in url and "git/trees" in url:
resp.status_code = 200
resp.json.return_value = tree_data
else:
resp.status_code = 404
return resp
with patch("turnstone.core.skill_sources.httpx.AsyncClient") as mock_client_cls:
instance = AsyncMock()
instance.get = AsyncMock(side_effect=mock_get)
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
mock_client_cls.return_value = instance
package = await fetch_skill_from_github("https://github.com/owner/repo")
assert package.parsed.name == "test-skill"
assert package.parsed.author == "Test Author"
assert package.listing.source == "github"
assert package.listing.id == "owner/repo/test-skill"
assert package.resources == {}
+37
View File
@@ -307,6 +307,9 @@ class SkillInfo(BaseModel):
notify_on_complete: str = "{}"
enabled: bool = True
allowed_tools: str = "[]"
scan_status: str = ""
scan_report: str = "{}"
scan_version: str = ""
created: str
updated: str
@@ -653,6 +656,40 @@ class McpReloadResponse(BaseModel):
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Admin: Skill Discovery
# ---------------------------------------------------------------------------
class SkillDiscoverListing(BaseModel):
id: str
name: str
description: str = ""
author: str = ""
source: str = ""
source_url: str = ""
install_count: int = 0
tags: list[str] = Field(default_factory=list)
installed: bool = False
scan_status: str = ""
template_id: str = ""
class SkillDiscoverResponse(BaseModel):
skills: list[SkillDiscoverListing]
class SkillInstallRequest(BaseModel):
source: str # "skills.sh" or "github"
skill_id: str = "" # for skills.sh
url: str = "" # for github
# ---------------------------------------------------------------------------
# Admin: MCP Registry
# ---------------------------------------------------------------------------
class RegistryRemoteInfo(BaseModel):
type: str = "streamable-http"
url: str = ""
+26
View File
@@ -50,7 +50,9 @@ from turnstone.api.console_schemas import (
RoleInfo,
SettingInfo,
SettingSchemaInfo,
SkillDiscoverResponse,
SkillInfo,
SkillInstallRequest,
SkillVersionInfo,
ToolPolicyInfo,
UpdateMcpServerRequest,
@@ -483,6 +485,28 @@ CONSOLE_ENDPOINTS: list[EndpointSpec] = [
error_codes=[404],
tags=["Admin"],
),
# --- Governance: Skill Discovery ---
EndpointSpec(
"/v1/api/admin/skills/discover",
"GET",
"Search external skill registries for available skills",
response_model=SkillDiscoverResponse,
query_params=[
QueryParam("q", "Search query"),
QueryParam("limit", "Max results (default 20, max 100)", schema_type="integer"),
],
error_codes=[502],
tags=["Admin"],
),
EndpointSpec(
"/v1/api/admin/skills/install",
"POST",
"Install a skill from an external source",
request_model=SkillInstallRequest,
response_model=SkillInfo,
error_codes=[400, 404, 409, 502],
tags=["Admin"],
),
# --- Governance: Skills ---
EndpointSpec(
"/v1/api/admin/skills",
@@ -859,6 +883,8 @@ _ALL_MODELS: list[type[BaseModel]] = [
McpReloadResponse,
RegistrySearchResponse,
RegistryInstallRequest,
SkillDiscoverResponse,
SkillInstallRequest,
SkillInfo,
SkillVersionInfo,
CreateSkillRequest,
+216
View File
@@ -2286,6 +2286,9 @@ def _skill_to_response(r: dict[str, Any]) -> dict[str, Any]:
"notify_on_complete": r.get("notify_on_complete", "{}"),
"enabled": r.get("enabled", True),
"allowed_tools": r.get("allowed_tools", "[]"),
"scan_status": r.get("scan_status", ""),
"scan_report": r.get("scan_report", "{}"),
"scan_version": r.get("scan_version", ""),
"created": r.get("created", ""),
"updated": r.get("updated", ""),
}
@@ -2826,6 +2829,212 @@ async def admin_rescan_skill(request: Request) -> JSONResponse:
)
# ---------------------------------------------------------------------------
# Admin: Skill Discovery
# ---------------------------------------------------------------------------
def _get_discovery_url(request: Request) -> str:
"""Get skills discovery URL from DB settings, config.toml, or default."""
from turnstone.core.config import load_config
from turnstone.core.skill_sources import DEFAULT_DISCOVERY_URL
storage = getattr(request.app.state, "auth_storage", None)
if storage:
try:
row = storage.get_system_setting("skills.discovery_url")
if row:
val = json.loads(row["value"])
if val:
return str(val)
except (KeyError, json.JSONDecodeError, TypeError, AttributeError):
pass
skills_cfg = load_config("skills")
url = skills_cfg.get("discovery_url", "")
if url:
return str(url)
return DEFAULT_DISCOVERY_URL
async def admin_skill_discover(request: Request) -> JSONResponse:
"""GET /v1/api/admin/skills/discover — search external skill registries."""
from turnstone.core.auth import require_permission
from turnstone.core.skill_sources import SkillSourceError, SkillsShClient
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.skills")
if err:
return err
q = str(request.query_params.get("q", "")).strip()
try:
limit = max(1, min(int(request.query_params.get("limit", "20")), 100))
except (ValueError, TypeError):
limit = 20
discovery_url = _get_discovery_url(request)
client = SkillsShClient(base_url=discovery_url)
try:
listings = await client.search(query=q, limit=limit)
except SkillSourceError as exc:
return JSONResponse({"error": f"Discovery error: {exc}"}, status_code=502)
# Mark which skills are already installed (by source_url match)
installed_map: dict[str, dict[str, str]] = {}
for row in storage.list_installed_skill_urls():
installed_map[row["source_url"]] = {
"scan_status": row.get("scan_status", ""),
"template_id": row.get("template_id", ""),
}
skills_out = []
for listing in listings:
is_installed = listing.source_url in installed_map if listing.source_url else False
entry: dict[str, Any] = {
"id": listing.id,
"name": listing.name,
"description": listing.description,
"author": listing.author,
"source": listing.source,
"source_url": listing.source_url,
"install_count": listing.install_count,
"tags": listing.tags,
"installed": is_installed,
}
if is_installed and listing.source_url:
info = installed_map[listing.source_url]
entry["scan_status"] = info["scan_status"]
entry["template_id"] = info["template_id"]
skills_out.append(entry)
return JSONResponse({"skills": skills_out})
async def admin_skill_install(request: Request) -> JSONResponse:
"""POST /v1/api/admin/skills/install — install a skill from external source."""
import uuid
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.skill_sources import (
SkillNotFoundError,
SkillSourceError,
SkillsShClient,
fetch_skill_from_github,
)
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.skills")
if err:
return err
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
source = str(body.get("source", "")).strip()
if source not in ("skills.sh", "github"):
return JSONResponse({"error": "source must be 'skills.sh' or 'github'"}, status_code=400)
try:
if source == "skills.sh":
skill_id_param = str(body.get("skill_id", "")).strip()
if not skill_id_param:
return JSONResponse({"error": "skill_id is required"}, status_code=400)
discovery_url = _get_discovery_url(request)
client = SkillsShClient(base_url=discovery_url)
github_url = await client.resolve_github_url(skill_id_param)
package = await fetch_skill_from_github(github_url)
else:
url = str(body.get("url", "")).strip()
if not url:
return JSONResponse({"error": "url is required"}, status_code=400)
package = await fetch_skill_from_github(url)
except SkillNotFoundError as exc:
return JSONResponse({"error": str(exc)}, status_code=404)
except SkillSourceError as exc:
return JSONResponse({"error": str(exc)}, status_code=502)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
# Check for duplicate by source_url
source_url = package.listing.source_url
if source_url:
existing = storage.get_skill_by_source_url(source_url)
if existing:
return JSONResponse(
{"error": f"Skill from '{source_url}' is already installed"},
status_code=409,
)
# Check for duplicate by name
if storage.get_prompt_template_by_name(package.parsed.name):
return JSONResponse(
{"error": f"Skill name '{package.parsed.name}' already exists"},
status_code=409,
)
import json as _json
audit_uid, ip = _audit_context(request)
skill_id = uuid.uuid4().hex
parsed = package.parsed
tags_str = _json.dumps(parsed.tags)
allowed_tools_str = _json.dumps(parsed.allowed_tools)
content = parsed.content[:32768]
token_estimate = len(content) // 4 if content else 0
storage.create_prompt_template(
template_id=skill_id,
name=parsed.name,
category="general",
content=content,
variables="[]",
is_default=False,
org_id="",
created_by=audit_uid,
origin="source",
readonly=True,
description=parsed.description,
tags=tags_str,
source_url=source_url,
version=parsed.version,
author=parsed.author,
activation="named",
token_estimate=token_estimate,
allowed_tools=allowed_tools_str,
)
# Store bundled resources
for path, content in package.resources.items():
storage.create_skill_resource(
resource_id=uuid.uuid4().hex,
skill_id=skill_id,
path=path,
content=content,
)
record_audit(
storage,
audit_uid,
"skill.install",
"skill",
skill_id,
{"name": parsed.name, "source": source, "source_url": source_url},
ip,
)
skill = storage.get_prompt_template(skill_id)
return JSONResponse(_skill_to_response(skill))
# ---------------------------------------------------------------------------
# Admin: Memories
# ---------------------------------------------------------------------------
@@ -4127,6 +4336,13 @@ def create_app(
admin_delete_policy,
methods=["DELETE"],
),
# Governance: Skill Discovery
Route("/api/admin/skills/discover", admin_skill_discover),
Route(
"/api/admin/skills/install",
admin_skill_install,
methods=["POST"],
),
# Governance: Skills
Route("/api/admin/skills", admin_list_skills),
Route("/api/admin/skills", admin_create_skill, methods=["POST"]),
+3 -1
View File
@@ -1840,6 +1840,7 @@ function _installTrap(overlayId, boxId, trapRef) {
else if (overlayId === "mcp-import-overlay") hideImportMcpModal();
else if (overlayId === "mcp-detail-overlay") hideMcpDetailModal();
else if (overlayId === "mcp-install-overlay") hideInstallMcpModal();
else if (overlayId === "github-import-overlay") hideGitHubImportModal();
}
};
}
@@ -1927,6 +1928,7 @@ document.addEventListener("keydown", function (e) {
["mcp-detail-overlay", hideMcpDetailModal],
["mcp-import-overlay", hideImportMcpModal],
["mcp-create-overlay", hideCreateMcpModal],
["github-import-overlay", hideGitHubImportModal],
];
for (var gi = 0; gi < govOverlays.length; gi++) {
var govEl = document.getElementById(govOverlays[gi][0]);
@@ -3243,7 +3245,7 @@ function submitImportMcp() {
function switchMcpView(view) {
_mcpCurrentView = view;
var btns = document.querySelectorAll(".mcp-view-btn");
var btns = document.querySelectorAll("#admin-mcp .mcp-view-btn");
for (var i = 0; i < btns.length; i++) {
var isActive = btns[i].getAttribute("data-mcp-view") === view;
btns[i].classList.toggle("active", isActive);
+293
View File
@@ -11,6 +11,11 @@ var _govUsageGroupBy = "day";
var _govAuditEvents = [];
var _govAuditTotal = 0;
var _govAuditOffset = 0;
var _skillCurrentView = "installed";
var _skillDiscoverResults = [];
var _skillDiscoverQuery = "";
var _giTrapHandler = null;
var _giTriggerEl = null;
// Trap handler refs for modals
var _crTrapHandler = null; // create role
@@ -1726,3 +1731,291 @@ function deleteAdminMemory(memoryId, memoryName) {
showToast("Error: " + e.message);
});
}
// ---------------------------------------------------------------------------
// Skill Discovery
// ---------------------------------------------------------------------------
function switchSkillView(view) {
_skillCurrentView = view;
var btns = document.querySelectorAll("#admin-skills [data-skill-view]");
for (var i = 0; i < btns.length; i++) {
var isActive = btns[i].getAttribute("data-skill-view") === view;
btns[i].classList.toggle("active", isActive);
btns[i].setAttribute("aria-selected", isActive ? "true" : "false");
btns[i].setAttribute("tabindex", isActive ? "0" : "-1");
}
document.getElementById("skill-view-installed").style.display =
view === "installed" ? "" : "none";
document.getElementById("skill-view-discover").style.display =
view === "discover" ? "" : "none";
var toolbar = document.getElementById("skill-installed-toolbar");
if (toolbar) toolbar.style.display = view === "installed" ? "" : "none";
if (view === "installed") {
loadGovSkills();
} else {
var q = document.getElementById("skill-discover-q");
if (q) q.focus();
}
}
function searchSkillDiscover() {
var q = (document.getElementById("skill-discover-q").value || "").trim();
_skillDiscoverResults = [];
_skillDiscoverQuery = q;
var el = document.getElementById("skill-discover-results");
el.innerHTML = '<div class="dashboard-empty">Searching\u2026</div>';
var searchBtn = document.getElementById("skill-discover-search-btn");
if (searchBtn) searchBtn.disabled = true;
var url = "/v1/api/admin/skills/discover?limit=20";
if (q) url += "&q=" + encodeURIComponent(q);
authFetch(url)
.then(function (r) {
if (!r.ok)
return r.json().then(function (d) {
throw new Error(d.error || "Search failed");
});
return r.json();
})
.then(function (data) {
_skillDiscoverResults = data.skills || [];
_renderSkillDiscoverResults();
})
.catch(function (e) {
el.innerHTML =
'<div class="dashboard-empty">' + escapeHtml(e.message) + "</div>";
})
.finally(function () {
if (searchBtn) searchBtn.disabled = false;
});
}
function _renderSkillDiscoverResults() {
var el = document.getElementById("skill-discover-results");
if (!_skillDiscoverResults.length) {
el.innerHTML = '<div class="dashboard-empty">No skills found</div>';
return;
}
var html = "";
for (var i = 0; i < _skillDiscoverResults.length; i++) {
var s = _skillDiscoverResults[i];
var nameLabel = escapeHtml(s.name || "");
var actionHtml;
if (s.installed) {
var scanBadgeHtml = "";
if (s.scan_status) {
var scanCls =
{
safe: "scope-scan-safe",
low: "scope-scan-low",
medium: "scope-scan-medium",
high: "scope-scan-high",
critical: "scope-scan-critical",
}[s.scan_status] || "";
scanBadgeHtml =
'<span class="scope-badge ' +
scanCls +
'" style="margin-right:4px">' +
escapeHtml(s.scan_status) +
"</span>";
}
actionHtml =
scanBadgeHtml + '<span class="mcp-installed-badge">Installed</span>';
} else {
actionHtml =
'<button class="mcp-install-btn" data-skill-install="' +
i +
'" aria-label="Install ' +
nameLabel +
'">Install</button>';
}
// Tags
var tagHtml = "";
var tags = s.tags || [];
for (var t = 0; t < tags.length && t < 4; t++) {
tagHtml += '<span class="scope-badge">' + escapeHtml(tags[t]) + "</span>";
}
// Source + install count badge
var metaHtml = "";
if (s.source) {
metaHtml +=
'<span class="scope-badge mcp-transport-http">' +
escapeHtml(s.source) +
"</span>";
}
if (s.install_count > 0) {
metaHtml +=
'<span class="mcp-reg-card-version">' +
s.install_count.toLocaleString() +
" installs</span>";
}
html +=
'<div class="mcp-reg-card" role="listitem">' +
'<div class="mcp-reg-card-info">' +
'<div class="mcp-reg-card-name">' +
nameLabel +
(s.author
? ' <span class="mcp-reg-card-version">by ' +
escapeHtml(s.author) +
"</span>"
: "") +
"</div>" +
(s.description
? '<div class="mcp-reg-card-desc">' +
escapeHtml(s.description) +
"</div>"
: "") +
'<div class="mcp-reg-card-meta">' +
tagHtml +
metaHtml +
"</div></div>" +
'<div class="mcp-reg-card-actions">' +
actionHtml +
"</div></div>";
}
el.innerHTML = html;
// Bind install handlers
el.querySelectorAll("[data-skill-install]").forEach(function (btn) {
btn.addEventListener("click", function () {
var idx = parseInt(this.getAttribute("data-skill-install"), 10);
installDiscoveredSkill(_skillDiscoverResults[idx]);
});
});
}
function installDiscoveredSkill(skill) {
if (!skill) return;
// Disable the button
var btns = document.querySelectorAll("[data-skill-install]");
for (var i = 0; i < btns.length; i++) {
var idx = parseInt(btns[i].getAttribute("data-skill-install"), 10);
if (
_skillDiscoverResults[idx] &&
_skillDiscoverResults[idx].id === skill.id
) {
btns[i].disabled = true;
btns[i].textContent = "Installing\u2026";
break;
}
}
var body;
if (skill.source === "github") {
body = { source: "github", url: skill.source_url };
} else {
body = { source: "skills.sh", skill_id: skill.id };
}
authFetch("/v1/api/admin/skills/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
.then(function (r) {
if (!r.ok)
return r.json().then(function (d) {
throw new Error(d.error || "Install failed");
});
return r.json();
})
.then(function (data) {
var tierMsg = data.scan_status ? " [" + data.scan_status + "]" : "";
showToast("Skill installed: " + (skill.name || skill.id) + tierMsg);
// Mark as installed in results with scan data
for (var j = 0; j < _skillDiscoverResults.length; j++) {
if (_skillDiscoverResults[j].id === skill.id) {
_skillDiscoverResults[j].installed = true;
_skillDiscoverResults[j].scan_status = data.scan_status || "";
break;
}
}
_renderSkillDiscoverResults();
})
.catch(function (e) {
showToast("Error: " + e.message);
_renderSkillDiscoverResults();
});
}
function showGitHubImportModal() {
_giTriggerEl = document.activeElement;
document.getElementById("github-import-overlay").style.display = "";
var urlInput = document.getElementById("gi-url");
urlInput.value = "";
var errEl = document.getElementById("github-import-error");
errEl.textContent = "";
errEl.style.display = "none";
_giTrapHandler = _installTrap("github-import-overlay", "github-import-box");
urlInput.focus();
}
function hideGitHubImportModal() {
document.getElementById("github-import-overlay").style.display = "none";
_giTrapHandler = _removeTrap(_giTrapHandler);
if (_giTriggerEl) {
_giTriggerEl.focus();
_giTriggerEl = null;
}
}
function submitGitHubImport() {
var url = (document.getElementById("gi-url").value || "").trim();
var errEl = document.getElementById("github-import-error");
if (!url) {
errEl.textContent = "URL is required";
errEl.style.display = "";
return;
}
if (!/^https?:\/\/github\.com\//i.test(url)) {
errEl.textContent = "Must be a GitHub URL";
errEl.style.display = "";
return;
}
var submitBtn = document.getElementById("gi-submit");
submitBtn.disabled = true;
submitBtn.textContent = "Installing\u2026";
errEl.style.display = "none";
authFetch("/v1/api/admin/skills/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source: "github", url: url }),
})
.then(function (r) {
if (!r.ok)
return r.json().then(function (d) {
throw new Error(d.error || "Install failed");
});
return r.json();
})
.then(function (data) {
hideGitHubImportModal();
var tierMsg = data.scan_status ? " [" + data.scan_status + "]" : "";
showToast("Skill installed: " + (data.name || "") + tierMsg);
// Refresh if we're on discover view
if (_skillCurrentView === "discover") {
searchSkillDiscover();
}
})
.catch(function (e) {
errEl.textContent = e.message;
errEl.style.display = "";
})
.finally(function () {
submitBtn.disabled = false;
submitBtn.textContent = "Install";
});
}
+48 -8
View File
@@ -258,16 +258,41 @@
<div id="admin-skills" class="admin-panel" role="tabpanel" aria-labelledby="tab-skills" style="display:none">
<div class="admin-toolbar">
<span class="section-header" style="margin:0">SKILLS</span>
<button class="admin-action-btn" onclick="showCreateTemplateModal()">+ Create skill</button>
<div class="mcp-view-toggle" role="tablist" aria-label="Skills view">
<button class="mcp-view-btn active" id="skill-tab-installed" data-skill-view="installed" role="tab" aria-selected="true" aria-controls="skill-view-installed" tabindex="0" onclick="switchSkillView('installed')">Installed</button>
<button class="mcp-view-btn" id="skill-tab-discover" data-skill-view="discover" role="tab" aria-selected="false" aria-controls="skill-view-discover" tabindex="-1" onclick="switchSkillView('discover')">Discover</button>
</div>
<span id="skill-installed-toolbar">
<button class="admin-action-btn" onclick="showCreateTemplateModal()">+ Create skill</button>
</span>
</div>
<div class="admin-colheaders" aria-hidden="true">
<span class="admin-col admin-col-tmname">NAME</span>
<span class="admin-col admin-col-tmcat">CATEGORY</span>
<span class="admin-col admin-col-tmvars">VARIABLES</span>
<span class="admin-col admin-col-actions">ACTIONS</span>
<!-- Installed view -->
<div id="skill-view-installed" role="tabpanel" aria-labelledby="skill-tab-installed">
<div class="admin-colheaders" aria-hidden="true">
<span class="admin-col admin-col-tmname">NAME</span>
<span class="admin-col admin-col-tmcat">CATEGORY</span>
<span class="admin-col admin-col-tmvars">VARIABLES</span>
<span class="admin-col admin-col-actions">ACTIONS</span>
</div>
<div id="admin-skills-table" role="list" aria-label="Skills" aria-live="polite">
<div class="dashboard-empty">No skills configured</div>
</div>
</div>
<div id="admin-skills-table" role="list" aria-label="Skills" aria-live="polite">
<div class="dashboard-empty">No skills configured</div>
<!-- Discover view -->
<div id="skill-view-discover" role="tabpanel" aria-labelledby="skill-tab-discover" style="display:none">
<div class="mcp-registry-notice" role="note">
<span class="mcp-registry-notice-icon" aria-hidden="true">&#9432;</span>
Skills are community-published and not vetted by Turnstone.<br>
<span style="margin-left:19px">Review source content before installing.</span>
</div>
<div class="mcp-registry-search">
<input id="skill-discover-q" type="search" placeholder="Search skills..." autocomplete="off" aria-label="Search skills" onkeydown="if(event.key==='Enter'){event.preventDefault();searchSkillDiscover()}">
<button id="skill-discover-search-btn" class="admin-action-btn" onclick="searchSkillDiscover()">Search</button>
<button class="admin-action-btn admin-action-btn-ghost" onclick="showGitHubImportModal()">Import from GitHub</button>
</div>
<div id="skill-discover-results" role="list" aria-label="Discovered skills">
<div class="dashboard-empty">Search external registries to discover and install skills</div>
</div>
</div>
</div>
@@ -488,6 +513,21 @@ window.TURNSTONE_KB_SHORTCUTS = [
</div>
</div>
<!-- GitHub Import Modal -->
<div id="github-import-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="github-import-title">
<div id="github-import-box" class="admin-modal">
<h2 id="github-import-title">Import from GitHub</h2>
<div id="github-import-error" role="alert" aria-live="assertive"></div>
<label for="gi-url">GitHub URL</label>
<input id="gi-url" type="url" placeholder="https://github.com/owner/repo" autocomplete="off" spellcheck="false" aria-describedby="gi-url-hint">
<p id="gi-url-hint" style="font-size:11px;color:var(--fg-dim);margin:4px 0 12px">Paste a link to a repository or SKILL.md file</p>
<div class="modal-buttons">
<button class="modal-cancel" onclick="hideGitHubImportModal()">Cancel</button>
<button id="gi-submit" class="modal-submit" onclick="submitGitHubImport()">Install</button>
</div>
</div>
</div>
<!-- Create User Modal -->
<div id="create-user-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="create-user-title">
<div id="create-user-box" class="admin-modal">
+4 -1
View File
@@ -1240,7 +1240,8 @@
#create-policy-overlay, #edit-policy-overlay,
#create-template-overlay, #edit-template-overlay,
#memory-detail-overlay,
#mcp-create-overlay, #mcp-import-overlay, #mcp-detail-overlay, #mcp-install-overlay {
#mcp-create-overlay, #mcp-import-overlay, #mcp-detail-overlay, #mcp-install-overlay,
#github-import-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
@@ -2031,6 +2032,8 @@
.mcp-registry-search{flex-direction:column}
#admin-mcp .admin-toolbar{flex-wrap:wrap;gap:8px}
#mcp-servers-toolbar{display:flex;gap:6px;width:100%}
#admin-skills .admin-toolbar{flex-wrap:wrap;gap:8px}
#skill-installed-toolbar{display:flex;gap:6px;width:100%}
}
/* ==========================================================================
+10
View File
@@ -424,6 +424,16 @@ def _build_registry() -> dict[str, SettingDef]:
"private keys, connection strings) are replaced with [REDACTED] markers "
"before tool output enters the conversation.",
),
# -- skills ---------------------------------------------------------
SettingDef(
"skills.discovery_url",
"str",
"",
"Skills discovery API URL (empty = skills.sh)",
"skills",
help="Override the skills discovery URL for enterprise or private skill registries. "
"Leave empty to use the default skills.sh registry.",
),
# -- memory ---------------------------------------------------------
SettingDef(
"memory.relevance_k",
+121
View File
@@ -0,0 +1,121 @@
"""SKILL.md parser — extract structured metadata from skill definition files.
Pure functions, no I/O. Accepts raw SKILL.md text and returns a
:class:`ParsedSkill` dataclass.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Any
import frontmatter
# Name validation: lowercase letters, digits, hyphens, max 64 chars
_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9\-]{0,62}[a-z0-9]$|^[a-z0-9]$")
@dataclass(frozen=True)
class ParsedSkill:
"""Structured representation of a SKILL.md file."""
name: str
description: str
content: str # markdown body (after frontmatter)
tags: list[str] = field(default_factory=list)
author: str = ""
version: str = "1.0.0"
allowed_tools: list[str] = field(default_factory=list)
license: str = ""
compatibility: str = ""
raw_frontmatter: dict[str, Any] = field(default_factory=dict)
def _extract_tags(meta: dict[str, Any]) -> list[str]:
"""Extract tags from frontmatter, handling both Anthropic and Hermes formats."""
# Direct tags field
tags = meta.get("tags")
if isinstance(tags, list):
return [str(t) for t in tags if t]
# Nested metadata.tags (Anthropic format)
metadata = meta.get("metadata")
if isinstance(metadata, dict):
nested = metadata.get("tags")
if isinstance(nested, list):
return [str(t) for t in nested if t]
# metadata.hermes.tags (Hermes format)
hermes = metadata.get("hermes")
if isinstance(hermes, dict):
hermes_tags = hermes.get("tags")
if isinstance(hermes_tags, list):
return [str(t) for t in hermes_tags if t]
return []
def _extract_list(meta: dict[str, Any], key: str) -> list[str]:
"""Extract a list of strings from frontmatter, with fallback."""
val = meta.get(key)
if isinstance(val, list):
return [str(v) for v in val if v]
if isinstance(val, str) and val:
return [v.strip() for v in val.split(",") if v.strip()]
return []
def validate_skill_name(name: str) -> str | None:
"""Validate a skill name. Returns error message or None if valid."""
if not name:
return "name is required"
if len(name) > 64:
return f"name exceeds 64 characters ({len(name)})"
if not _NAME_RE.match(name):
return "name must be lowercase alphanumeric with hyphens (e.g. 'code-review')"
return None
def parse_skill_md(raw: str) -> ParsedSkill:
"""Parse SKILL.md (YAML frontmatter + markdown body).
Handles missing or malformed frontmatter gracefully returns a
``ParsedSkill`` with defaults for any missing fields.
Raises ``ValueError`` if ``name`` is missing or invalid.
"""
try:
post = frontmatter.loads(raw)
except Exception as exc:
raise ValueError(f"Failed to parse SKILL.md frontmatter: {exc}") from exc
meta: dict[str, Any] = dict(post.metadata)
body = post.content.strip()
# Required: name
name = str(meta.get("name", "")).strip().lower()
name_err = validate_skill_name(name)
if name_err:
raise ValueError(name_err)
# Description — frontmatter or first paragraph of body
description = str(meta.get("description", "")).strip()
if not description and body:
first_line = body.split("\n")[0].strip()
# Skip markdown headings
if first_line.startswith("#"):
first_line = first_line.lstrip("# ").strip()
description = first_line[:256]
return ParsedSkill(
name=name,
description=description,
content=body,
tags=_extract_tags(meta),
author=str(meta.get("author", "")).strip(),
version=str(meta.get("version", "1.0.0")).strip(),
allowed_tools=_extract_list(meta, "allowed_tools"),
license=str(meta.get("license", "")).strip(),
compatibility=str(meta.get("compatibility", "")).strip(),
raw_frontmatter=meta,
)
+274
View File
@@ -0,0 +1,274 @@
"""Skill discovery source clients — skills.sh API + GitHub fetcher.
Provides :class:`SkillsShClient` for searching the skills.sh registry
and :func:`fetch_skill_from_github` for fetching SKILL.md from GitHub repos.
"""
from __future__ import annotations
import logging
import os
import re
from dataclasses import dataclass, field
from typing import Any
from urllib.parse import quote
import httpx
from turnstone.core.skill_parser import ParsedSkill, parse_skill_md
logger = logging.getLogger(__name__)
DEFAULT_DISCOVERY_URL = "https://skills.sh"
_GITHUB_URL_RE = re.compile(
r"^https?://github\.com/(?P<owner>[a-zA-Z0-9_-]+)/(?P<repo>[a-zA-Z0-9._-]+)"
r"(?:/(?:tree|blob)/(?P<branch>[^/]+)(?:/(?P<path>.+))?)?$"
)
_MAX_RESOURCE_FILES = 10
_MAX_RESOURCE_SIZE = 100 * 1024 # 100KB per file
_MAX_SKILL_MD_SIZE = 256 * 1024 # 256KB generous cap for SKILL.md
_RESOURCE_DIRS = ("scripts", "references", "assets")
_TEXT_EXTENSIONS = frozenset(
{".md", ".txt", ".sh", ".py", ".js", ".ts", ".json", ".yaml", ".yml", ".toml", ".cfg", ".ini"}
)
@dataclass(frozen=True)
class SkillListing:
"""A skill discovered from an external source."""
id: str # "owner/repo/skill-name" or registry ID
name: str
description: str = ""
author: str = ""
source: str = "" # "skills.sh" | "github"
source_url: str = ""
install_count: int = 0
tags: list[str] = field(default_factory=list)
@dataclass(frozen=True)
class SkillPackage:
"""A fully resolved skill ready for installation."""
listing: SkillListing
parsed: ParsedSkill
resources: dict[str, str] = field(default_factory=dict) # path → content
class SkillSourceError(Exception):
"""Error communicating with a skill source."""
class SkillNotFoundError(SkillSourceError):
"""Skill definition (SKILL.md) not found at the source."""
class SkillsShClient:
"""Async client for the skills.sh discovery API."""
def __init__(self, base_url: str = "") -> None:
self._base_url = (base_url or DEFAULT_DISCOVERY_URL).rstrip("/")
async def search(self, query: str = "", *, limit: int = 20) -> list[SkillListing]:
"""Search for skills matching *query*."""
params: dict[str, str | int] = {"limit": min(limit, 100)}
if query:
params["q"] = query
async with httpx.AsyncClient(follow_redirects=True, timeout=10.0) as client:
try:
resp = await client.get(f"{self._base_url}/api/search", params=params)
resp.raise_for_status()
except httpx.HTTPStatusError as exc:
raise SkillSourceError(f"skills.sh returned {exc.response.status_code}") from exc
except httpx.HTTPError as exc:
raise SkillSourceError(f"skills.sh request failed: {exc}") from exc
data = resp.json()
results: list[SkillListing] = []
for item in data.get("skills", data.get("results", [])):
results.append(
SkillListing(
id=str(item.get("id", item.get("name", ""))),
name=str(item.get("name", "")),
description=str(item.get("description", "")),
author=str(item.get("author", "")),
source="skills.sh",
source_url=str(item.get("source_url", item.get("url", ""))),
install_count=int(item.get("install_count", item.get("installs", 0))),
tags=[str(t) for t in item.get("tags", []) if isinstance(t, str)],
)
)
return results
async def resolve_github_url(self, skill_id: str) -> str:
"""Resolve a skills.sh skill ID to its GitHub URL."""
async with httpx.AsyncClient(follow_redirects=True, timeout=10.0) as client:
try:
resp = await client.get(f"{self._base_url}/api/skills/{quote(skill_id, safe='')}")
resp.raise_for_status()
except httpx.HTTPError as exc:
raise SkillSourceError(f"Failed to resolve skill {skill_id}: {exc}") from exc
data = resp.json()
url = str(data.get("source_url", data.get("github_url", data.get("url", ""))))
if not url:
raise SkillSourceError(f"No source URL for skill {skill_id}")
return url
def _parse_github_url(url: str) -> tuple[str, str, str, str]:
"""Parse a GitHub URL into (owner, repo, branch, path).
Returns ("", "", "", "") if URL doesn't match.
"""
m = _GITHUB_URL_RE.match(url)
if not m:
return ("", "", "", "")
return (
m.group("owner"),
m.group("repo"),
m.group("branch") or "main",
m.group("path") or "",
)
async def fetch_skill_from_github(url: str) -> SkillPackage:
"""Fetch a SKILL.md and bundled resources from a GitHub repository.
Tries the following paths in order:
1. Direct path from URL (if it points to a SKILL.md)
2. ``SKILL.md`` at repo root
3. ``skills/{name}/SKILL.md`` for monorepos (inferred from path)
Uses ``TURNSTONE_GITHUB_TOKEN`` env var for authenticated requests
(60 5000 req/hr rate limit headroom).
"""
owner, repo, branch, path = _parse_github_url(url)
if not owner:
raise SkillSourceError(f"Could not parse GitHub URL: {url}")
headers: dict[str, str] = {"Accept": "application/vnd.github.v3+json"}
token = os.environ.get("TURNSTONE_GITHUB_TOKEN", "")
if token:
headers["Authorization"] = f"Bearer {token}"
# When branch isn't specified in URL, try main then master
branch_explicit = bool(_GITHUB_URL_RE.match(url) and _GITHUB_URL_RE.match(url).group("branch")) # type: ignore[union-attr]
branches_to_try = [branch] if branch_explicit else ["main", "master"]
api_base = f"https://api.github.com/repos/{owner}/{repo}"
# Determine SKILL.md path candidates
path = path.rstrip("/")
candidates: list[str] = []
if path:
if path.endswith("SKILL.md"):
candidates.append(path)
else:
candidates.append(f"{path}/SKILL.md")
candidates.append("SKILL.md")
# Try skills/{last_segment}/SKILL.md for monorepos
if path:
last_seg = path.rsplit("/", 1)[-1]
candidates.append(f"skills/{last_seg}/SKILL.md")
# De-duplicate preserving order
seen: set[str] = set()
unique_candidates: list[str] = []
for c in candidates:
if c not in seen:
seen.add(c)
unique_candidates.append(c)
skill_md_content = ""
skill_md_dir = ""
resolved_branch = branch
async with httpx.AsyncClient(follow_redirects=True, timeout=15.0, headers=headers) as client:
# Try each branch × candidate combination
for try_branch in branches_to_try:
raw_base = f"https://raw.githubusercontent.com/{owner}/{repo}/{try_branch}"
for candidate in unique_candidates:
try:
resp = await client.get(f"{raw_base}/{candidate}")
if resp.status_code == 200:
content_len = int(resp.headers.get("content-length", "0"))
if content_len > _MAX_SKILL_MD_SIZE:
continue
skill_md_content = resp.text[:_MAX_SKILL_MD_SIZE]
# Directory containing the SKILL.md
parts = candidate.rsplit("/", 1)
skill_md_dir = parts[0] if len(parts) > 1 else ""
resolved_branch = try_branch
break
except httpx.HTTPError:
continue
if skill_md_content:
break
if not skill_md_content:
raise SkillNotFoundError(
f"SKILL.md not found in {owner}/{repo} (tried {unique_candidates})"
)
parsed = parse_skill_md(skill_md_content)
# Fetch bundled resources via GitHub API tree endpoint
resources: dict[str, str] = {}
raw_base = f"https://raw.githubusercontent.com/{owner}/{repo}/{resolved_branch}"
try:
tree_resp = await client.get(
f"{api_base}/git/trees/{resolved_branch}",
params={"recursive": "1"},
)
if tree_resp.status_code == 200 and len(tree_resp.content) < 2 * 1024 * 1024:
tree_data = tree_resp.json()
resource_files: list[dict[str, Any]] = []
for item in tree_data.get("tree", []):
if item.get("type") != "blob":
continue
item_path: str = item.get("path", "")
# Filter to resource dirs relative to SKILL.md location
rel_path = item_path
if skill_md_dir:
if not item_path.startswith(f"{skill_md_dir}/"):
continue
rel_path = item_path[len(skill_md_dir) + 1 :]
# Check if it's in a resource directory
first_seg = rel_path.split("/")[0] if "/" in rel_path else ""
if first_seg not in _RESOURCE_DIRS:
continue
# Filter to text-safe extensions only
ext = os.path.splitext(rel_path)[1].lower()
if ext not in _TEXT_EXTENSIONS:
continue
size = item.get("size", 0)
if size > _MAX_RESOURCE_SIZE:
continue
resource_files.append({"path": rel_path, "full_path": item_path})
# Fetch up to MAX files
for rf in resource_files[:_MAX_RESOURCE_FILES]:
try:
content_resp = await client.get(f"{raw_base}/{rf['full_path']}")
if content_resp.status_code == 200:
resources[rf["path"]] = content_resp.text
except httpx.HTTPError:
continue
except httpx.HTTPError:
logger.debug("Failed to fetch resource tree for %s/%s", owner, repo)
listing = SkillListing(
id=f"{owner}/{repo}/{parsed.name}",
name=parsed.name,
description=parsed.description,
author=parsed.author,
source="github",
source_url=url,
tags=parsed.tags,
)
return SkillPackage(listing=listing, parsed=parsed, resources=resources)
+27
View File
@@ -1693,6 +1693,33 @@ class PostgreSQLBackend:
def get_skill_by_name(self, name: str) -> dict[str, Any] | None:
return self.get_prompt_template_by_name(name)
def get_skill_by_source_url(self, source_url: str) -> dict[str, Any] | None:
with self._engine.connect() as conn:
row = conn.execute(
sa.select(prompt_templates).where(prompt_templates.c.source_url == source_url)
).fetchone()
if row:
return _row_to_dict(row, "is_default", "readonly", "auto_approve", "enabled")
return None
def list_installed_skill_urls(self) -> list[dict[str, str]]:
with self._engine.connect() as conn:
rows = conn.execute(
sa.select(
prompt_templates.c.source_url,
prompt_templates.c.template_id,
prompt_templates.c.scan_status,
).where(prompt_templates.c.source_url != "")
).fetchall()
return [
{
"source_url": r[0],
"template_id": r[1],
"scan_status": r[2] or "",
}
for r in rows
]
# -- Skill resources -------------------------------------------------------
def create_skill_resource(
+8
View File
@@ -625,6 +625,14 @@ class StorageBackend(Protocol):
"""Lookup skill (prompt template) by name. Returns dict or None."""
...
def get_skill_by_source_url(self, source_url: str) -> dict[str, Any] | None:
"""Lookup skill (prompt template) by source_url. Returns dict or None."""
...
def list_installed_skill_urls(self) -> list[dict[str, str]]:
"""Return [{source_url, template_id, scan_status}] for skills with non-empty source_url."""
...
# -- Skill resources -------------------------------------------------------
def create_skill_resource(
+27
View File
@@ -1717,6 +1717,33 @@ class SQLiteBackend:
def get_skill_by_name(self, name: str) -> dict[str, Any] | None:
return self.get_prompt_template_by_name(name)
def get_skill_by_source_url(self, source_url: str) -> dict[str, Any] | None:
with self._engine.connect() as conn:
row = conn.execute(
sa.select(prompt_templates).where(prompt_templates.c.source_url == source_url)
).fetchone()
if row:
return _row_to_dict(row, "is_default", "readonly", "auto_approve", "enabled")
return None
def list_installed_skill_urls(self) -> list[dict[str, str]]:
with self._engine.connect() as conn:
rows = conn.execute(
sa.select(
prompt_templates.c.source_url,
prompt_templates.c.template_id,
prompt_templates.c.scan_status,
).where(prompt_templates.c.source_url != "")
).fetchall()
return [
{
"source_url": r[0],
"template_id": r[1],
"scan_status": r[2] or "",
}
for r in rows
]
# -- Skill resources -------------------------------------------------------
def create_skill_resource(
+62
View File
@@ -37,6 +37,8 @@ from turnstone.api.console_schemas import (
RegistrySearchResponse,
RoleInfo,
SettingInfo,
SkillDiscoverResponse,
SkillInfo,
ToolPolicyInfo,
UsageResponse,
)
@@ -740,6 +742,47 @@ class AsyncTurnstoneConsole(_BaseClient):
response_model=McpServerDetail,
)
# -- skill discovery -----------------------------------------------------
async def discover_skills(
self,
q: str = "",
*,
limit: int = 20,
) -> SkillDiscoverResponse:
"""Search external skill registries for available skills."""
params: dict[str, Any] = {}
if q:
params["q"] = q
if limit != 20:
params["limit"] = limit
return await self._request(
"GET",
"/v1/api/admin/skills/discover",
params=params,
response_model=SkillDiscoverResponse,
)
async def install_skill(
self,
source: str,
*,
skill_id: str = "",
url: str = "",
) -> SkillInfo:
"""Install a skill from an external source."""
body: dict[str, Any] = {"source": source}
if skill_id:
body["skill_id"] = skill_id
if url:
body["url"] = url
return await self._request(
"POST",
"/v1/api/admin/skills/install",
json_body=body,
response_model=SkillInfo,
)
class TurnstoneConsole:
"""Synchronous client for the turnstone console API.
@@ -1160,6 +1203,25 @@ class TurnstoneConsole:
)
)
# -- skill discovery -----------------------------------------------------
def discover_skills(
self,
q: str = "",
*,
limit: int = 20,
) -> SkillDiscoverResponse:
return self._runner.run(self._async.discover_skills(q, limit=limit))
def install_skill(
self,
source: str,
*,
skill_id: str = "",
url: str = "",
) -> SkillInfo:
return self._runner.run(self._async.install_skill(source, skill_id=skill_id, url=url))
# -- lifecycle -----------------------------------------------------------
def close(self) -> None:
Generated
+74
View File
@@ -749,6 +749,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86", size = 278890, upload-time = "2026-02-20T20:19:39.263Z" },
{ url = "https://files.pythonhosted.org/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f", size = 581120, upload-time = "2026-02-20T20:47:30.161Z" },
{ url = "https://files.pythonhosted.org/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55", size = 594363, upload-time = "2026-02-20T20:55:56.965Z" },
{ url = "https://files.pythonhosted.org/packages/9c/8b/1430a04657735a3f23116c2e0d5eb10220928846e4537a938a41b350bed6/greenlet-3.3.2-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4375a58e49522698d3e70cc0b801c19433021b5c37686f7ce9c65b0d5c8677d2", size = 605046, upload-time = "2026-02-20T21:02:45.234Z" },
{ url = "https://files.pythonhosted.org/packages/72/83/3e06a52aca8128bdd4dcd67e932b809e76a96ab8c232a8b025b2850264c5/greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358", size = 594156, upload-time = "2026-02-20T20:20:59.955Z" },
{ url = "https://files.pythonhosted.org/packages/70/79/0de5e62b873e08fe3cef7dbe84e5c4bc0e8ed0c7ff131bccb8405cd107c8/greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99", size = 1554649, upload-time = "2026-02-20T20:49:32.293Z" },
{ url = "https://files.pythonhosted.org/packages/5a/00/32d30dee8389dc36d42170a9c66217757289e2afb0de59a3565260f38373/greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be", size = 1619472, upload-time = "2026-02-20T20:21:07.966Z" },
@@ -757,6 +758,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" },
{ url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" },
{ url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" },
{ url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" },
{ url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" },
{ url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" },
{ url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" },
@@ -765,6 +767,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" },
{ url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" },
{ url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" },
{ url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" },
{ url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" },
{ url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" },
{ url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" },
@@ -773,6 +776,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" },
{ url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" },
{ url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" },
{ url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" },
{ url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" },
{ url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" },
@@ -781,6 +785,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" },
{ url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" },
{ url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" },
{ url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" },
{ url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" },
{ url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" },
{ url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" },
@@ -1735,6 +1740,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "python-frontmatter"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyyaml" },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/de/910fa208120314a12f9a88ea63e03707261692af782c99283f1a2c8a5e6f/python-frontmatter-1.1.0.tar.gz", hash = "sha256:7118d2bd56af9149625745c58c9b51fb67e8d1294a0c76796dafdc72c36e5f6d", size = 16256, upload-time = "2024-01-16T18:50:04.052Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/87/3c8da047b3ec5f99511d1b4d7a5bc72d4b98751c7e78492d14dc736319c5/python_frontmatter-1.1.0-py3-none-any.whl", hash = "sha256:335465556358d9d0e6c98bbeb69b1c969f2a4a21360587b9873bfc3b213407c1", size = 9834, upload-time = "2024-01-16T18:50:00.911Z" },
]
[[package]]
name = "python-multipart"
version = "0.0.22"
@@ -1763,6 +1780,61 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "redis"
version = "7.3.0"
@@ -2107,6 +2179,7 @@ dependencies = [
{ name = "openai" },
{ name = "pydantic" },
{ name = "pyjwt" },
{ name = "python-frontmatter" },
{ name = "sqlalchemy" },
{ name = "sse-starlette" },
{ name = "starlette" },
@@ -2171,6 +2244,7 @@ requires-dist = [
{ name = "pyjwt", specifier = ">=2.8" },
{ name = "pytest", marker = "extra == 'test'", specifier = ">=9.0" },
{ name = "pytest-cov", marker = "extra == 'test'", specifier = ">=6.0" },
{ name = "python-frontmatter", specifier = ">=1.0" },
{ name = "redis", marker = "extra == 'console'", specifier = ">=7.2" },
{ name = "redis", marker = "extra == 'discord'", specifier = ">=7.2" },
{ name = "redis", marker = "extra == 'mq'", specifier = ">=7.2" },