Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
with:
python-version: '3.11'
- name: Install dependencies
run: pip install -r tools/requirements.txt pytest pytest-cov pip-audit ruff
run: pip install -e '.[dev]'
- name: Lint (Makefile lint target)
# Wire the repo Makefile lint target into CI: ruff check over tools/.
run: make lint
Expand All @@ -45,6 +45,8 @@ jobs:
run: python3 tools/update_registry.py --check
- name: Run tests
run: python -m pytest tests/ -v --cov --cov-report=term-missing --cov-fail-under=88
- name: Packaging smoke check
run: make package-check
- name: Security audit
run: python3 tools/check_secrets.py --strict
- name: Shell-command audit
Expand Down
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ validate: registry-check ## Validate all skills in the registry
pytest: ## Run pytest unit tests
python3 -m pytest tests/ -v

test: boundary-guard lint validate pytest ## Run all checks
package-check: ## Smoke-package a sample skill and verify the archive + metadata
python3 tools/package_skill.py categories/python/ai-sdk-python --skip-validate --output-dir /tmp/graycode-skill-pkg

test: boundary-guard lint validate pytest package-check ## Run all checks

help: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
Expand Down
18 changes: 16 additions & 2 deletions manifest-schema.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@
#
# Every skill in this registry MUST include a SKILL.md file with YAML
# frontmatter that conforms to this schema. The schema is expressed in TOML
# for human readability; the CI validation script (scripts/validate-skill.py)
# enforces it programmatically.
# for human readability; the CI validation tool (tools/validate_skill.py)
# enforces the *enforced* schema recorded under [enforced] below, which is the
# single source of truth for the corpus gate. The per-field `required = true`
# markers under [fields] describe the forward (v2.0) target and are NOT yet
# required of the existing corpus.
#
# Required fields are marked required = true.
# Optional fields default to the value shown under "default".
Expand Down Expand Up @@ -199,3 +202,14 @@ type = "array"
items.type = "string"
required = false
description = "Skills that work well together (advisory). Part of SmartSkill.Chain.Enhances."

# ── Enforced schema (single source of truth for the CI gate) ──────────────
# tools/validate_skill.py reads these values so the corpus gate and this
# document cannot drift. These are the fields/limits the 14k-skill corpus
# actually satisfies today. The aspirational v2.0 fields (version, author,
# domain, tags) under [fields] are the forward target, not yet enforced.
[enforced]
required_fields = ["name", "description", "license"]
max_description_length = 200
min_tags = 1
max_tags = 5
11 changes: 10 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,22 @@ build-backend = "hatchling.build"
name = "graycode-skills"
version = "0.0.1"
description = "Community skill packages for Graycode"
requires-python = ">=3.13"
requires-python = ">=3.11"
dependencies = [
"pyyaml>=6.0",
"rich>=13.0",
"cryptography>=42.0",
]

[project.optional-dependencies]
# Reproducible dev/test toolchain. Install with: pip install -e '.[dev]'
dev = [
"pytest>=8.0",
"pytest-cov>=5.0",
"ruff>=0.5",
"pip-audit>=2.7",
]

[tool.ruff]
target-version = "py39"
line-length = 100
Expand Down
74 changes: 74 additions & 0 deletions tests/test_package_skill.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""Tests for tools/package_skill.py."""

from __future__ import annotations

import json
import subprocess
import sys
import tarfile
from pathlib import Path

import pytest

sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "tools"))

from package_skill import SAFE_NAME_RE, SAFE_VERSION_RE, _sanitized_identifier, compute_checksum

REPO_ROOT = Path(__file__).resolve().parent.parent


@pytest.fixture
def skill_dir(tmp_path: Path) -> Path:
d = tmp_path / "my-skill"
d.mkdir()
(d / "SKILL.md").write_text(
"---\nname: my-skill\ndescription: A test skill\nlicense: MIT\nversion: 1.0\n---\n# My Skill\n",
encoding="utf-8",
)
(d / "helper.sh").write_text("#!/bin/sh\necho hi\n", encoding="utf-8")
return d


def test_sanitized_identifier_accepts_safe():
assert _sanitized_identifier("my-skill", "name", SAFE_NAME_RE) == "my-skill"
assert _sanitized_identifier("1.0", "version", SAFE_VERSION_RE) == "1.0"


def test_sanitized_identifier_rejects_path_traversal():
with pytest.raises(SystemExit):
_sanitized_identifier("../../../../tmp/pwned", "name", SAFE_NAME_RE)


def test_compute_checksum(tmp_path: Path):
f = tmp_path / "data.txt"
f.write_bytes(b"hello world")
assert compute_checksum(f) == "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"


def test_package_skill_end_to_end(skill_dir: Path, tmp_path: Path):
out = tmp_path / "dist"
result = subprocess.run(
[
sys.executable,
str(REPO_ROOT / "tools" / "package_skill.py"),
str(skill_dir),
"--skip-validate",
"--output-dir",
str(out),
],
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stdout + result.stderr
archive = out / "my-skill-1.0.tar.gz"
meta = out / "my-skill-1.0.meta.json"
assert archive.exists()
assert meta.exists()
metadata = json.loads(meta.read_text(encoding="utf-8"))
assert metadata["name"] == "my-skill"
assert metadata["version"] == "1.0"
assert metadata["sha256"] == compute_checksum(archive)
with tarfile.open(archive, "r:gz") as tar:
names = tar.getnames()
assert "my-skill/SKILL.md" in names
assert "my-skill/helper.sh" in names
21 changes: 21 additions & 0 deletions tests/test_validate_skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
ValidationResult,
compare_warning_budget,
find_all_skills,
load_enforced_schema,
load_warning_budget,
main,
path_exists_with_exact_case,
Expand Down Expand Up @@ -809,3 +810,23 @@ def test_asset_file_not_warned(self, skill_dir: Path):
(skill_dir / "screenshot.png").write_bytes(large_asset)
result = validate_skill(skill_dir)
assert not any("exceeds 100KB" in w for w in result.warnings)


class TestEnforcedSchema:
def test_loader_reads_manifest_schema_toml(self):
"""load_enforced_schema must read the [enforced] section of
manifest-schema.toml (the single source of truth) so the corpus gate
and the schema document cannot drift."""
enforced = load_enforced_schema()
assert set(enforced["required_fields"]) == {"name", "description", "license"}
assert enforced["max_description_length"] == 200
assert enforced["min_tags"] == 1
assert enforced["max_tags"] == 5

def test_loader_falls_back_on_missing_file(self, monkeypatch, tmp_path):
"""A missing/unparseable schema must fall back to the historical
defaults rather than relaxing validation."""
monkeypatch.setattr("validate_skill.REPO_ROOT", tmp_path)
enforced = load_enforced_schema()
assert set(enforced["required_fields"]) == {"name", "description", "license"}
assert enforced["max_tags"] == 5
36 changes: 32 additions & 4 deletions tools/validate_skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
from pathlib import Path
from urllib.parse import unquote, urlsplit

import tomllib

try:
from rich.console import Console
from rich.table import Table
Expand All @@ -28,8 +30,34 @@
REPO_ROOT = Path(__file__).resolve().parent.parent
CATEGORIES_DIR = REPO_ROOT / "categories"

REQUIRED_FIELDS = {"name", "description", "license"}
MAX_DESCRIPTION_LEN = 200

def load_enforced_schema() -> dict:
"""Load the enforced-schema section from manifest-schema.toml, the single
source of truth for the corpus gate. Falls back to the historical hardcoded
values if the file or section is missing, so a schema parse problem can
never silently relax validation."""
defaults = {
"required_fields": ["name", "description", "license"],
"max_description_length": 200,
"min_tags": 1,
"max_tags": 5,
}
try:
with open(REPO_ROOT / "manifest-schema.toml", "rb") as fh:
data = tomllib.load(fh)
enforced = data.get("enforced", {})
merged = dict(defaults)
for key in defaults:
if key in enforced:
merged[key] = enforced[key]
return merged
except (OSError, tomllib.TOMLDecodeError):
return defaults


_ENFORCED = load_enforced_schema()
REQUIRED_FIELDS = set(_ENFORCED["required_fields"])
MAX_DESCRIPTION_LEN = _ENFORCED["max_description_length"]
MAX_FILE_SIZE = 100 * 1024 # 100KB — warning threshold
# Hard limit for SKILL.md itself: a skill definition this large is almost
# certainly bulk content that belongs in reference files, and it bloats every
Expand All @@ -41,8 +69,8 @@
SIZE_ALLOWLIST_PATH = Path(__file__).resolve().parent / "skill_size_allowlist.txt"
ASSET_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".ico", ".pdf"}
TAG_PATTERN = re.compile(r"^[a-z][a-z0-9]*(-[a-z0-9]+)*$")
MIN_TAGS = 1
MAX_TAGS = 5
MIN_TAGS = _ENFORCED["min_tags"]
MAX_TAGS = _ENFORCED["max_tags"]

# Agent Skills spec (agentskills.io) — recognized optional frontmatter fields.
# These are informational for graycode-skills but must be well-formed
Expand Down
Loading