mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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 (1–100) |
|
||||
|
||||
**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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +171,10 @@ export type {
|
||||
RegistryServerInfo,
|
||||
RegistrySearchResponse,
|
||||
RegistryInstallRequest,
|
||||
// Skill discovery types
|
||||
SkillDiscoverListing,
|
||||
SkillDiscoverResponse,
|
||||
SkillInstallRequest,
|
||||
} from "./types.js";
|
||||
|
||||
// SSE parser (for advanced usage)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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 == {}
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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">ⓘ</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">
|
||||
|
||||
@@ -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%}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user