mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-15 00:12:26 -06:00
c28bfc1e58
* 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
250 lines
5.5 KiB
Python
250 lines
5.5 KiB
Python
"""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
|