From 547831f3a23724ba64a9b79bbf59c5e0bc8f2d1a Mon Sep 17 00:00:00 2001 From: Keith Lazuka Date: Thu, 18 Dec 2025 09:39:21 -0500 Subject: [PATCH] Add skills-ref library (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reference library for Agent Skills with CLI and Python API: - validate: Check skill directories for valid SKILL.md with proper frontmatter - read-properties: Parse and output skill properties as JSON - to-prompt: Generate suggested XML for agent prompts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude --- .gitignore | 6 + skills-ref/.gitignore | 22 ++ skills-ref/CLAUDE.md | 18 ++ skills-ref/LICENSE | 202 +++++++++++++++++ skills-ref/README.md | 85 ++++++++ skills-ref/pyproject.toml | 30 +++ skills-ref/src/skills_ref/__init__.py | 20 ++ skills-ref/src/skills_ref/cli.py | 105 +++++++++ skills-ref/src/skills_ref/errors.py | 25 +++ skills-ref/src/skills_ref/models.py | 39 ++++ skills-ref/src/skills_ref/parser.py | 112 ++++++++++ skills-ref/src/skills_ref/prompt.py | 58 +++++ skills-ref/src/skills_ref/validator.py | 177 +++++++++++++++ skills-ref/tests/__init__.py | 1 + skills-ref/tests/test_parser.py | 188 ++++++++++++++++ skills-ref/tests/test_prompt.py | 70 ++++++ skills-ref/tests/test_validator.py | 290 +++++++++++++++++++++++++ skills-ref/uv.lock | 162 ++++++++++++++ 18 files changed, 1610 insertions(+) create mode 100644 .gitignore create mode 100644 skills-ref/.gitignore create mode 100644 skills-ref/CLAUDE.md create mode 100644 skills-ref/LICENSE create mode 100644 skills-ref/README.md create mode 100644 skills-ref/pyproject.toml create mode 100644 skills-ref/src/skills_ref/__init__.py create mode 100644 skills-ref/src/skills_ref/cli.py create mode 100644 skills-ref/src/skills_ref/errors.py create mode 100644 skills-ref/src/skills_ref/models.py create mode 100644 skills-ref/src/skills_ref/parser.py create mode 100644 skills-ref/src/skills_ref/prompt.py create mode 100644 skills-ref/src/skills_ref/validator.py create mode 100644 skills-ref/tests/__init__.py create mode 100644 skills-ref/tests/test_parser.py create mode 100644 skills-ref/tests/test_prompt.py create mode 100644 skills-ref/tests/test_validator.py create mode 100644 skills-ref/uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ac6e62 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# macOS +.DS_Store + +# IDEs +.idea/ +.vscode/ diff --git a/skills-ref/.gitignore b/skills-ref/.gitignore new file mode 100644 index 0000000..b10659c --- /dev/null +++ b/skills-ref/.gitignore @@ -0,0 +1,22 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +.eggs/ + +# Virtual environments +.venv/ +venv/ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# Type checking +.mypy_cache/ + +# Linting +.ruff_cache/ diff --git a/skills-ref/CLAUDE.md b/skills-ref/CLAUDE.md new file mode 100644 index 0000000..35d4cf1 --- /dev/null +++ b/skills-ref/CLAUDE.md @@ -0,0 +1,18 @@ +# Development + +## Code Quality + +Format and lint with ruff: + +```bash +uv run ruff format . +uv run ruff check --fix . +``` + +## Testing + +Run tests with pytest: + +```bash +uv run pytest +``` diff --git a/skills-ref/LICENSE b/skills-ref/LICENSE new file mode 100644 index 0000000..7a4a3ea --- /dev/null +++ b/skills-ref/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/skills-ref/README.md b/skills-ref/README.md new file mode 100644 index 0000000..78b59e0 --- /dev/null +++ b/skills-ref/README.md @@ -0,0 +1,85 @@ +# skills-ref + +Reference library for Agent Skills. + +> **Note:** This library is intended for demonstration purposes only. It is not meant to be used in production. + +## Installation + +Using pip: + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -e . +``` + +Or using [uv](https://docs.astral.sh/uv/): + +```bash +uv sync +source .venv/bin/activate +``` + +After installation, the `skills-ref` executable will be available on your `$PATH` (within the activated virtual environment). + +## Usage + +### CLI + +```bash +# Validate a skill +skills-ref validate path/to/skill + +# Read skill properties (outputs JSON) +skills-ref read-properties path/to/skill + +# Generate XML for agent prompts +skills-ref to-prompt path/to/skill-a path/to/skill-b +``` + +### Python API + +```python +from pathlib import Path +from skills_ref import validate, read_properties, to_prompt + +# Validate a skill directory +problems = validate(Path("my-skill")) +if problems: + print("Validation errors:", problems) + +# Read skill properties +props = read_properties(Path("my-skill")) +print(f"Skill: {props.name} - {props.description}") + +# Generate prompt for available skills +prompt = to_prompt([Path("skill-a"), Path("skill-b")]) +print(prompt) +``` + +## Agent Prompt Integration + +Use `to-prompt` to generate the suggested `` XML block for your agent's system prompt. This format is recommended for Anthropic's models, but Skill Clients may choose to format it differently based on the model being used. + +```xml + + + +my-skill + + +What this skill does and when to use it + + +/path/to/my-skill/SKILL.md + + + +``` + +The `` element tells the agent where to find the full skill instructions. + +## License + +Apache 2.0 diff --git a/skills-ref/pyproject.toml b/skills-ref/pyproject.toml new file mode 100644 index 0000000..26f0bd3 --- /dev/null +++ b/skills-ref/pyproject.toml @@ -0,0 +1,30 @@ +[project] +name = "skills-ref" +version = "0.1.0" +description = "Reference library for Agent Skills" +license = "Apache-2.0" +readme = "README.md" +authors = [ + { name = "Keith Lazuka", email = "klazuka@anthropic.com" } +] +requires-python = ">=3.11" +dependencies = [ + "click>=8.0", + "strictyaml>=1.7.3", +] + +[project.scripts] +skills-ref = "skills_ref.cli:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/skills_ref"] + +[dependency-groups] +dev = [ + "pytest>=7.0", + "ruff>=0.8.0", +] diff --git a/skills-ref/src/skills_ref/__init__.py b/skills-ref/src/skills_ref/__init__.py new file mode 100644 index 0000000..43776da --- /dev/null +++ b/skills-ref/src/skills_ref/__init__.py @@ -0,0 +1,20 @@ +"""Reference library for Agent Skills.""" + +from .errors import ParseError, SkillError, ValidationError +from .models import SkillProperties +from .parser import find_skill_md, read_properties +from .prompt import to_prompt +from .validator import validate + +__all__ = [ + "SkillError", + "ParseError", + "ValidationError", + "SkillProperties", + "find_skill_md", + "validate", + "read_properties", + "to_prompt", +] + +__version__ = "0.1.0" diff --git a/skills-ref/src/skills_ref/cli.py b/skills-ref/src/skills_ref/cli.py new file mode 100644 index 0000000..9af9fc3 --- /dev/null +++ b/skills-ref/src/skills_ref/cli.py @@ -0,0 +1,105 @@ +"""CLI for skills-ref library.""" + +import json +import sys +from pathlib import Path + +import click + +from .errors import SkillError +from .parser import read_properties +from .prompt import to_prompt +from .validator import validate + + +def _is_skill_md_file(path: Path) -> bool: + """Check if path points directly to a SKILL.md or skill.md file.""" + return path.is_file() and path.name.lower() == "skill.md" + + +@click.group() +@click.version_option() +def main(): + """Reference library for Agent Skills.""" + pass + + +@main.command("validate") +@click.argument("skill_path", type=click.Path(exists=True, path_type=Path)) +def validate_cmd(skill_path: Path): + """Validate a skill directory. + + Checks that the skill has a valid SKILL.md with proper frontmatter, + correct naming conventions, and required fields. + + Exit codes: + 0: Valid skill + 1: Validation errors found + """ + if _is_skill_md_file(skill_path): + skill_path = skill_path.parent + + errors = validate(skill_path) + + if errors: + click.echo(f"Validation failed for {skill_path}:", err=True) + for error in errors: + click.echo(f" - {error}", err=True) + sys.exit(1) + else: + click.echo(f"Valid skill: {skill_path}") + + +@main.command("read-properties") +@click.argument("skill_path", type=click.Path(exists=True, path_type=Path)) +def read_properties_cmd(skill_path: Path): + """Read and print skill properties as JSON. + + Parses the YAML frontmatter from SKILL.md and outputs the + properties as JSON. + + Exit codes: + 0: Success + 1: Parse error + """ + try: + if _is_skill_md_file(skill_path): + skill_path = skill_path.parent + + props = read_properties(skill_path) + click.echo(json.dumps(props.to_dict(), indent=2)) + except SkillError as e: + click.echo(f"Error: {e}", err=True) + sys.exit(1) + + +@main.command("to-prompt") +@click.argument( + "skill_paths", type=click.Path(exists=True, path_type=Path), nargs=-1, required=True +) +def to_prompt_cmd(skill_paths: tuple[Path, ...]): + """Generate XML for agent prompts. + + Accepts one or more skill directories. + + Exit codes: + 0: Success + 1: Error + """ + try: + resolved_paths = [] + for skill_path in skill_paths: + if _is_skill_md_file(skill_path): + resolved_paths.append(skill_path.parent) + else: + resolved_paths.append(skill_path) + + output = to_prompt(resolved_paths) + click.echo(output) + except SkillError as e: + click.echo(f"Error: {e}", err=True) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/skills-ref/src/skills_ref/errors.py b/skills-ref/src/skills_ref/errors.py new file mode 100644 index 0000000..e9b49b5 --- /dev/null +++ b/skills-ref/src/skills_ref/errors.py @@ -0,0 +1,25 @@ +"""Skill-related exceptions.""" + + +class SkillError(Exception): + """Base exception for all skill-related errors.""" + + pass + + +class ParseError(SkillError): + """Raised when SKILL.md parsing fails.""" + + pass + + +class ValidationError(SkillError): + """Raised when skill properties are invalid. + + Attributes: + errors: List of validation error messages (may contain just one) + """ + + def __init__(self, message: str, errors: list[str] | None = None): + super().__init__(message) + self.errors = errors if errors is not None else [message] diff --git a/skills-ref/src/skills_ref/models.py b/skills-ref/src/skills_ref/models.py new file mode 100644 index 0000000..77fa89e --- /dev/null +++ b/skills-ref/src/skills_ref/models.py @@ -0,0 +1,39 @@ +"""Data models for Agent Skills.""" + +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class SkillProperties: + """Properties parsed from a skill's SKILL.md frontmatter. + + Attributes: + name: Skill name in kebab-case (required) + description: What the skill does and when the model should use it (required) + license: License for the skill (optional) + compatibility: Compatibility information for the skill (optional) + allowed_tools: Tool patterns the skill requires (optional, experimental) + metadata: Key-value pairs for client-specific properties (defaults to + empty dict; omitted from to_dict() output when empty) + """ + + name: str + description: str + license: Optional[str] = None + compatibility: Optional[str] = None + allowed_tools: Optional[str] = None + metadata: dict[str, str] = field(default_factory=dict) + + def to_dict(self) -> dict: + """Convert to dictionary, excluding None values.""" + result = {"name": self.name, "description": self.description} + if self.license is not None: + result["license"] = self.license + if self.compatibility is not None: + result["compatibility"] = self.compatibility + if self.allowed_tools is not None: + result["allowed-tools"] = self.allowed_tools + if self.metadata: + result["metadata"] = self.metadata + return result diff --git a/skills-ref/src/skills_ref/parser.py b/skills-ref/src/skills_ref/parser.py new file mode 100644 index 0000000..690c14e --- /dev/null +++ b/skills-ref/src/skills_ref/parser.py @@ -0,0 +1,112 @@ +"""YAML frontmatter parsing for SKILL.md files.""" + +from pathlib import Path +from typing import Optional + +import strictyaml + +from .errors import ParseError, ValidationError +from .models import SkillProperties + + +def find_skill_md(skill_dir: Path) -> Optional[Path]: + """Find the SKILL.md file in a skill directory. + + Prefers SKILL.md (uppercase) but accepts skill.md (lowercase). + + Args: + skill_dir: Path to the skill directory + + Returns: + Path to the SKILL.md file, or None if not found + """ + for name in ("SKILL.md", "skill.md"): + path = skill_dir / name + if path.exists(): + return path + return None + + +def parse_frontmatter(content: str) -> tuple[dict, str]: + """Parse YAML frontmatter from SKILL.md content. + + Args: + content: Raw content of SKILL.md file + + Returns: + Tuple of (metadata dict, markdown body) + + Raises: + ParseError: If frontmatter is missing or invalid + """ + if not content.startswith("---"): + raise ParseError("SKILL.md must start with YAML frontmatter (---)") + + parts = content.split("---", 2) + if len(parts) < 3: + raise ParseError("SKILL.md frontmatter not properly closed with ---") + + frontmatter_str = parts[1] + body = parts[2].strip() + + try: + parsed = strictyaml.load(frontmatter_str) + metadata = parsed.data + except strictyaml.YAMLError as e: + raise ParseError(f"Invalid YAML in frontmatter: {e}") + + if not isinstance(metadata, dict): + raise ParseError("SKILL.md frontmatter must be a YAML mapping") + + if "metadata" in metadata and isinstance(metadata["metadata"], dict): + metadata["metadata"] = {str(k): str(v) for k, v in metadata["metadata"].items()} + + return metadata, body + + +def read_properties(skill_dir: Path) -> SkillProperties: + """Read skill properties from SKILL.md frontmatter. + + This function parses the frontmatter and returns properties. + It does NOT perform full validation. Use validate() for that. + + Args: + skill_dir: Path to the skill directory + + Returns: + SkillProperties with parsed metadata + + Raises: + ParseError: If SKILL.md is missing or has invalid YAML + ValidationError: If required fields (name, description) are missing + """ + skill_dir = Path(skill_dir) + skill_md = find_skill_md(skill_dir) + + if skill_md is None: + raise ParseError(f"SKILL.md not found in {skill_dir}") + + content = skill_md.read_text() + metadata, _ = parse_frontmatter(content) + + if "name" not in metadata: + raise ValidationError("Missing required field in frontmatter: name") + if "description" not in metadata: + raise ValidationError("Missing required field in frontmatter: description") + + name = metadata["name"] + description = metadata["description"] + + if not isinstance(name, str) or not name.strip(): + raise ValidationError("Field 'name' must be a non-empty string") + if not isinstance(description, str) or not description.strip(): + raise ValidationError("Field 'description' must be a non-empty string") + + return SkillProperties( + name=name.strip(), + description=description.strip(), + license=metadata.get("license"), + compatibility=metadata.get("compatibility"), + allowed_tools=metadata.get("allowed-tools"), + metadata=metadata.get("metadata"), + ) diff --git a/skills-ref/src/skills_ref/prompt.py b/skills-ref/src/skills_ref/prompt.py new file mode 100644 index 0000000..3a7d714 --- /dev/null +++ b/skills-ref/src/skills_ref/prompt.py @@ -0,0 +1,58 @@ +"""Generate XML prompt block for agent system prompts.""" + +import html +from pathlib import Path + +from .parser import find_skill_md, read_properties + + +def to_prompt(skill_dirs: list[Path]) -> str: + """Generate the XML block for inclusion in agent prompts. + + This XML format is what Anthropic uses and recommends for Claude models. + Skill Clients may format skill information differently to suit their + models or preferences. + + Args: + skill_dirs: List of paths to skill directories + + Returns: + XML string with block containing each skill's + name, description, and location. + + Example output: + + + pdf-reader + Read and extract text from PDF files + /path/to/pdf-reader/SKILL.md + + + """ + if not skill_dirs: + return "\n" + + lines = [""] + + for skill_dir in skill_dirs: + skill_dir = Path(skill_dir).resolve() + props = read_properties(skill_dir) + + lines.append("") + lines.append("") + lines.append(html.escape(props.name)) + lines.append("") + lines.append("") + lines.append(html.escape(props.description)) + lines.append("") + + skill_md_path = find_skill_md(skill_dir) + lines.append("") + lines.append(str(skill_md_path)) + lines.append("") + + lines.append("") + + lines.append("") + + return "\n".join(lines) diff --git a/skills-ref/src/skills_ref/validator.py b/skills-ref/src/skills_ref/validator.py new file mode 100644 index 0000000..22cf6f8 --- /dev/null +++ b/skills-ref/src/skills_ref/validator.py @@ -0,0 +1,177 @@ +"""Skill validation logic.""" + +import unicodedata +from pathlib import Path +from typing import Optional + +from .errors import ParseError +from .parser import find_skill_md, parse_frontmatter + +MAX_SKILL_NAME_LENGTH = 64 +MAX_DESCRIPTION_LENGTH = 1024 +MAX_COMPATIBILITY_LENGTH = 500 + +# Allowed frontmatter fields per Agent Skills Spec +ALLOWED_FIELDS = { + "name", + "description", + "license", + "allowed-tools", + "metadata", + "compatibility", +} + + +def _validate_name(name: str, skill_dir: Path) -> list[str]: + """Validate skill name format and directory match. + + Skill names support i18n characters (Unicode letters) plus hyphens. + Names must be lowercase and cannot start/end with hyphens. + """ + errors = [] + + if not name or not isinstance(name, str) or not name.strip(): + errors.append("Field 'name' must be a non-empty string") + return errors + + name = unicodedata.normalize("NFKC", name.strip()) + + if len(name) > MAX_SKILL_NAME_LENGTH: + errors.append( + f"Skill name '{name}' exceeds {MAX_SKILL_NAME_LENGTH} character limit " + f"({len(name)} chars)" + ) + + if name != name.lower(): + errors.append(f"Skill name '{name}' must be lowercase") + + if name.startswith("-") or name.endswith("-"): + errors.append("Skill name cannot start or end with a hyphen") + + if "--" in name: + errors.append("Skill name cannot contain consecutive hyphens") + + if not all(c.isalnum() or c == "-" for c in name): + errors.append( + f"Skill name '{name}' contains invalid characters. " + "Only letters, digits, and hyphens are allowed." + ) + + if skill_dir: + dir_name = unicodedata.normalize("NFKC", skill_dir.name) + if dir_name != name: + errors.append( + f"Directory name '{skill_dir.name}' must match skill name '{name}'" + ) + + return errors + + +def _validate_description(description: str) -> list[str]: + """Validate description format.""" + errors = [] + + if not description or not isinstance(description, str) or not description.strip(): + errors.append("Field 'description' must be a non-empty string") + return errors + + if len(description) > MAX_DESCRIPTION_LENGTH: + errors.append( + f"Description exceeds {MAX_DESCRIPTION_LENGTH} character limit " + f"({len(description)} chars)" + ) + + return errors + + +def _validate_compatibility(compatibility: str) -> list[str]: + """Validate compatibility format.""" + errors = [] + + if not isinstance(compatibility, str): + errors.append("Field 'compatibility' must be a string") + return errors + + if len(compatibility) > MAX_COMPATIBILITY_LENGTH: + errors.append( + f"Compatibility exceeds {MAX_COMPATIBILITY_LENGTH} character limit " + f"({len(compatibility)} chars)" + ) + + return errors + + +def _validate_metadata_fields(metadata: dict) -> list[str]: + """Validate that only allowed fields are present.""" + errors = [] + + extra_fields = set(metadata.keys()) - ALLOWED_FIELDS + if extra_fields: + errors.append( + f"Unexpected fields in frontmatter: {', '.join(sorted(extra_fields))}. " + f"Only {sorted(ALLOWED_FIELDS)} are allowed." + ) + + return errors + + +def validate_metadata(metadata: dict, skill_dir: Optional[Path] = None) -> list[str]: + """Validate parsed skill metadata. + + This is the core validation function that works on already-parsed metadata, + avoiding duplicate file I/O when called from the parser. + + Args: + metadata: Parsed YAML frontmatter dictionary + skill_dir: Optional path to skill directory (for name-directory match check) + + Returns: + List of validation error messages. Empty list means valid. + """ + errors = [] + errors.extend(_validate_metadata_fields(metadata)) + + if "name" not in metadata: + errors.append("Missing required field in frontmatter: name") + else: + errors.extend(_validate_name(metadata["name"], skill_dir)) + + if "description" not in metadata: + errors.append("Missing required field in frontmatter: description") + else: + errors.extend(_validate_description(metadata["description"])) + + if "compatibility" in metadata: + errors.extend(_validate_compatibility(metadata["compatibility"])) + + return errors + + +def validate(skill_dir: Path) -> list[str]: + """Validate a skill directory. + + Args: + skill_dir: Path to the skill directory + + Returns: + List of validation error messages. Empty list means valid. + """ + skill_dir = Path(skill_dir) + + if not skill_dir.exists(): + return [f"Path does not exist: {skill_dir}"] + + if not skill_dir.is_dir(): + return [f"Not a directory: {skill_dir}"] + + skill_md = find_skill_md(skill_dir) + if skill_md is None: + return ["Missing required file: SKILL.md"] + + try: + content = skill_md.read_text() + metadata, _ = parse_frontmatter(content) + except ParseError as e: + return [str(e)] + + return validate_metadata(metadata, skill_dir) diff --git a/skills-ref/tests/__init__.py b/skills-ref/tests/__init__.py new file mode 100644 index 0000000..e33067f --- /dev/null +++ b/skills-ref/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for skills-ref library.""" diff --git a/skills-ref/tests/test_parser.py b/skills-ref/tests/test_parser.py new file mode 100644 index 0000000..c1b9c4a --- /dev/null +++ b/skills-ref/tests/test_parser.py @@ -0,0 +1,188 @@ +"""Tests for parser module.""" + +import pytest + +from skills_ref.parser import ( + ParseError, + ValidationError, + find_skill_md, + parse_frontmatter, + read_properties, +) + + +def test_valid_frontmatter(): + content = """--- +name: my-skill +description: A test skill +--- +# My Skill + +Instructions here. +""" + metadata, body = parse_frontmatter(content) + assert metadata["name"] == "my-skill" + assert metadata["description"] == "A test skill" + assert "# My Skill" in body + + +def test_missing_frontmatter(): + content = "# No frontmatter here" + with pytest.raises(ParseError, match="must start with YAML frontmatter"): + parse_frontmatter(content) + + +def test_unclosed_frontmatter(): + content = """--- +name: my-skill +description: A test skill +""" + with pytest.raises(ParseError, match="not properly closed"): + parse_frontmatter(content) + + +def test_invalid_yaml(): + content = """--- +name: [invalid +description: broken +--- +Body here +""" + with pytest.raises(ParseError, match="Invalid YAML"): + parse_frontmatter(content) + + +def test_non_dict_frontmatter(): + content = """--- +- just +- a +- list +--- +Body +""" + with pytest.raises(ParseError, match="must be a YAML mapping"): + parse_frontmatter(content) + + +def test_read_valid_skill(tmp_path): + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("""--- +name: my-skill +description: A test skill +license: MIT +--- +# My Skill +""") + props = read_properties(skill_dir) + assert props.name == "my-skill" + assert props.description == "A test skill" + assert props.license == "MIT" + + +def test_read_with_metadata(tmp_path): + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("""--- +name: my-skill +description: A test skill +metadata: + author: Test Author + version: 1.0 +--- +Body +""") + props = read_properties(skill_dir) + assert props.metadata == {"author": "Test Author", "version": "1.0"} + + +def test_missing_skill_md(tmp_path): + with pytest.raises(ParseError, match="SKILL.md not found"): + read_properties(tmp_path) + + +def test_missing_name(tmp_path): + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("""--- +description: A test skill +--- +Body +""") + with pytest.raises(ValidationError, match="Missing required field.*name"): + read_properties(skill_dir) + + +def test_missing_description(tmp_path): + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("""--- +name: my-skill +--- +Body +""") + with pytest.raises(ValidationError, match="Missing required field.*description"): + read_properties(skill_dir) + + +def test_find_skill_md_prefers_uppercase(tmp_path): + """SKILL.md should be preferred over skill.md when both exist.""" + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("uppercase") + (skill_dir / "skill.md").write_text("lowercase") + result = find_skill_md(skill_dir) + assert result is not None + assert result.name == "SKILL.md" + + +def test_find_skill_md_accepts_lowercase(tmp_path): + """skill.md should be accepted when SKILL.md doesn't exist.""" + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + (skill_dir / "skill.md").write_text("lowercase") + result = find_skill_md(skill_dir) + assert result is not None + # Check case-insensitively since some filesystems are case-insensitive + assert result.name.lower() == "skill.md" + + +def test_find_skill_md_returns_none_when_missing(tmp_path): + """find_skill_md should return None when no skill.md exists.""" + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + result = find_skill_md(skill_dir) + assert result is None + + +def test_read_properties_with_lowercase_skill_md(tmp_path): + """read_properties should work with lowercase skill.md.""" + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + (skill_dir / "skill.md").write_text("""--- +name: my-skill +description: A test skill +--- +# My Skill +""") + props = read_properties(skill_dir) + assert props.name == "my-skill" + assert props.description == "A test skill" + + +def test_read_with_allowed_tools(tmp_path): + """allowed-tools should be parsed into SkillProperties.""" + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("""--- +name: my-skill +description: A test skill +allowed-tools: Bash(jq:*) Bash(git:*) +--- +Body +""") + props = read_properties(skill_dir) + assert props.allowed_tools == "Bash(jq:*) Bash(git:*)" + # Verify to_dict outputs as "allowed-tools" (hyphenated) + d = props.to_dict() + assert d["allowed-tools"] == "Bash(jq:*) Bash(git:*)" diff --git a/skills-ref/tests/test_prompt.py b/skills-ref/tests/test_prompt.py new file mode 100644 index 0000000..3173c5e --- /dev/null +++ b/skills-ref/tests/test_prompt.py @@ -0,0 +1,70 @@ +"""Tests for prompt module.""" + +from skills_ref.prompt import to_prompt + + +def test_empty_list(): + result = to_prompt([]) + assert result == "\n" + + +def test_single_skill(tmp_path): + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("""--- +name: my-skill +description: A test skill +--- +Body +""") + result = to_prompt([skill_dir]) + assert "" in result + assert "" in result + assert "\nmy-skill\n" in result + assert "\nA test skill\n" in result + assert "" in result + assert "SKILL.md" in result + + +def test_multiple_skills(tmp_path): + skill_a = tmp_path / "skill-a" + skill_a.mkdir() + (skill_a / "SKILL.md").write_text("""--- +name: skill-a +description: First skill +--- +Body +""") + + skill_b = tmp_path / "skill-b" + skill_b.mkdir() + (skill_b / "SKILL.md").write_text("""--- +name: skill-b +description: Second skill +--- +Body +""") + + result = to_prompt([skill_a, skill_b]) + assert result.count("") == 2 + assert result.count("") == 2 + assert "skill-a" in result + assert "skill-b" in result + + +def test_special_characters_escaped(tmp_path): + """XML special characters in description are escaped.""" + skill_dir = tmp_path / "special-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("""--- +name: special-skill +description: Use & tags +--- +Body +""") + result = to_prompt([skill_dir]) + assert "<foo>" in result + assert "&" in result + assert "<bar>" in result + assert "" not in result + assert "" not in result diff --git a/skills-ref/tests/test_validator.py b/skills-ref/tests/test_validator.py new file mode 100644 index 0000000..3b5e89d --- /dev/null +++ b/skills-ref/tests/test_validator.py @@ -0,0 +1,290 @@ +"""Tests for validator module.""" + +from skills_ref.validator import validate + + +def test_valid_skill(tmp_path): + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("""--- +name: my-skill +description: A test skill +--- +# My Skill +""") + errors = validate(skill_dir) + assert errors == [] + + +def test_nonexistent_path(tmp_path): + errors = validate(tmp_path / "nonexistent") + assert len(errors) == 1 + assert "does not exist" in errors[0] + + +def test_not_a_directory(tmp_path): + file_path = tmp_path / "file.txt" + file_path.write_text("test") + errors = validate(file_path) + assert len(errors) == 1 + assert "Not a directory" in errors[0] + + +def test_missing_skill_md(tmp_path): + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + errors = validate(skill_dir) + assert len(errors) == 1 + assert "Missing required file: SKILL.md" in errors[0] + + +def test_invalid_name_uppercase(tmp_path): + skill_dir = tmp_path / "MySkill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("""--- +name: MySkill +description: A test skill +--- +Body +""") + errors = validate(skill_dir) + assert any("lowercase" in e for e in errors) + + +def test_name_too_long(tmp_path): + long_name = "a" * 70 # Exceeds 64 char limit + skill_dir = tmp_path / long_name + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text(f"""--- +name: {long_name} +description: A test skill +--- +Body +""") + errors = validate(skill_dir) + assert any("exceeds" in e and "character limit" in e for e in errors) + + +def test_name_leading_hyphen(tmp_path): + skill_dir = tmp_path / "-my-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("""--- +name: -my-skill +description: A test skill +--- +Body +""") + errors = validate(skill_dir) + assert any("cannot start or end with a hyphen" in e for e in errors) + + +def test_name_consecutive_hyphens(tmp_path): + skill_dir = tmp_path / "my--skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("""--- +name: my--skill +description: A test skill +--- +Body +""") + errors = validate(skill_dir) + assert any("consecutive hyphens" in e for e in errors) + + +def test_name_invalid_characters(tmp_path): + skill_dir = tmp_path / "my_skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("""--- +name: my_skill +description: A test skill +--- +Body +""") + errors = validate(skill_dir) + assert any("invalid characters" in e for e in errors) + + +def test_name_directory_mismatch(tmp_path): + skill_dir = tmp_path / "wrong-name" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("""--- +name: correct-name +description: A test skill +--- +Body +""") + errors = validate(skill_dir) + assert any("must match skill name" in e for e in errors) + + +def test_unexpected_fields(tmp_path): + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("""--- +name: my-skill +description: A test skill +unknown_field: should not be here +--- +Body +""") + errors = validate(skill_dir) + assert any("Unexpected fields" in e for e in errors) + + +def test_valid_with_all_fields(tmp_path): + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("""--- +name: my-skill +description: A test skill +license: MIT +metadata: + author: Test +--- +Body +""") + errors = validate(skill_dir) + assert errors == [] + + +def test_allowed_tools_accepted(tmp_path): + """allowed-tools is accepted (experimental feature).""" + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("""--- +name: my-skill +description: A test skill +allowed-tools: Bash(jq:*) Bash(git:*) +--- +Body +""") + errors = validate(skill_dir) + assert errors == [] + + +def test_i18n_chinese_name(tmp_path): + """Chinese characters are allowed in skill names.""" + skill_dir = tmp_path / "技能" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("""--- +name: 技能 +description: A skill with Chinese name +--- +Body +""") + errors = validate(skill_dir) + assert errors == [] + + +def test_i18n_russian_name_with_hyphens(tmp_path): + """Russian names with hyphens are allowed.""" + skill_dir = tmp_path / "мой-навык" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("""--- +name: мой-навык +description: A skill with Russian name +--- +Body +""") + errors = validate(skill_dir) + assert errors == [] + + +def test_i18n_russian_lowercase_valid(tmp_path): + """Russian lowercase names should be accepted.""" + skill_dir = tmp_path / "навык" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("""--- +name: навык +description: A skill with Russian lowercase name +--- +Body +""") + errors = validate(skill_dir) + assert errors == [] + + +def test_i18n_russian_uppercase_rejected(tmp_path): + """Russian uppercase names should be rejected.""" + skill_dir = tmp_path / "НАВЫК" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("""--- +name: НАВЫК +description: A skill with Russian uppercase name +--- +Body +""") + errors = validate(skill_dir) + assert any("lowercase" in e for e in errors) + + +def test_description_too_long(tmp_path): + """Description exceeding 1024 chars should fail.""" + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + long_desc = "x" * 1100 + (skill_dir / "SKILL.md").write_text(f"""--- +name: my-skill +description: {long_desc} +--- +Body +""") + errors = validate(skill_dir) + assert any("exceeds" in e and "1024" in e for e in errors) + + +def test_valid_compatibility(tmp_path): + """Valid compatibility field should be accepted.""" + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text("""--- +name: my-skill +description: A test skill +compatibility: Requires Python 3.11+ +--- +Body +""") + errors = validate(skill_dir) + assert errors == [] + + +def test_compatibility_too_long(tmp_path): + """Compatibility exceeding 500 chars should fail.""" + skill_dir = tmp_path / "my-skill" + skill_dir.mkdir() + long_compat = "x" * 550 + (skill_dir / "SKILL.md").write_text(f"""--- +name: my-skill +description: A test skill +compatibility: {long_compat} +--- +Body +""") + errors = validate(skill_dir) + assert any("exceeds" in e and "500" in e for e in errors) + + +def test_nfkc_normalization(tmp_path): + """Skill names are NFKC normalized before validation. + + The name 'café' can be represented two ways: + - Precomposed: 'café' (4 chars, 'é' is U+00E9) + - Decomposed: 'café' (5 chars, 'e' + combining acute U+0301) + + NFKC normalizes both to the precomposed form. + """ + # Use decomposed form: 'cafe' + combining acute accent (U+0301) + decomposed_name = "cafe\u0301" # 'café' with combining accent + composed_name = "café" # precomposed form + + # Directory uses composed form, SKILL.md uses decomposed - should match after normalization + skill_dir = tmp_path / composed_name + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text(f"""--- +name: {decomposed_name} +description: A test skill +--- +Body +""") + errors = validate(skill_dir) + assert errors == [], f"Expected no errors, got: {errors}" diff --git a/skills-ref/uv.lock b/skills-ref/uv.lock new file mode 100644 index 0000000..747fa03 --- /dev/null +++ b/skills-ref/uv.lock @@ -0,0 +1,162 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/56/f013048ac4bc4c1d9be45afd4ab209ea62822fb1598f40687e6bf45dcea4/pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8", size = 1564125, upload-time = "2025-11-12T13:05:09.333Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad", size = 373668, upload-time = "2025-11-12T13:05:07.379Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/5b/dd7406afa6c95e3d8fa9d652b6d6dd17dd4a6bf63cb477014e8ccd3dcd46/ruff-0.14.7.tar.gz", hash = "sha256:3417deb75d23bd14a722b57b0a1435561db65f0ad97435b4cf9f85ffcef34ae5", size = 5727324, upload-time = "2025-11-28T20:55:10.525Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/b1/7ea5647aaf90106f6d102230e5df874613da43d1089864da1553b899ba5e/ruff-0.14.7-py3-none-linux_armv6l.whl", hash = "sha256:b9d5cb5a176c7236892ad7224bc1e63902e4842c460a0b5210701b13e3de4fca", size = 13414475, upload-time = "2025-11-28T20:54:54.569Z" }, + { url = "https://files.pythonhosted.org/packages/af/19/fddb4cd532299db9cdaf0efdc20f5c573ce9952a11cb532d3b859d6d9871/ruff-0.14.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3f64fe375aefaf36ca7d7250292141e39b4cea8250427482ae779a2aa5d90015", size = 13634613, upload-time = "2025-11-28T20:55:17.54Z" }, + { url = "https://files.pythonhosted.org/packages/40/2b/469a66e821d4f3de0440676ed3e04b8e2a1dc7575cf6fa3ba6d55e3c8557/ruff-0.14.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93e83bd3a9e1a3bda64cb771c0d47cda0e0d148165013ae2d3554d718632d554", size = 12765458, upload-time = "2025-11-28T20:55:26.128Z" }, + { url = "https://files.pythonhosted.org/packages/f1/05/0b001f734fe550bcfde4ce845948ac620ff908ab7241a39a1b39bb3c5f49/ruff-0.14.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3838948e3facc59a6070795de2ae16e5786861850f78d5914a03f12659e88f94", size = 13236412, upload-time = "2025-11-28T20:55:28.602Z" }, + { url = "https://files.pythonhosted.org/packages/11/36/8ed15d243f011b4e5da75cd56d6131c6766f55334d14ba31cce5461f28aa/ruff-0.14.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24c8487194d38b6d71cd0fd17a5b6715cda29f59baca1defe1e3a03240f851d1", size = 13182949, upload-time = "2025-11-28T20:55:33.265Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cf/fcb0b5a195455729834f2a6eadfe2e4519d8ca08c74f6d2b564a4f18f553/ruff-0.14.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:79c73db6833f058a4be8ffe4a0913b6d4ad41f6324745179bd2aa09275b01d0b", size = 13816470, upload-time = "2025-11-28T20:55:08.203Z" }, + { url = "https://files.pythonhosted.org/packages/7f/5d/34a4748577ff7a5ed2f2471456740f02e86d1568a18c9faccfc73bd9ca3f/ruff-0.14.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:12eb7014fccff10fc62d15c79d8a6be4d0c2d60fe3f8e4d169a0d2def75f5dad", size = 15289621, upload-time = "2025-11-28T20:55:30.837Z" }, + { url = "https://files.pythonhosted.org/packages/53/53/0a9385f047a858ba133d96f3f8e3c9c66a31cc7c4b445368ef88ebeac209/ruff-0.14.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c623bbdc902de7ff715a93fa3bb377a4e42dd696937bf95669118773dbf0c50", size = 14975817, upload-time = "2025-11-28T20:55:24.107Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d7/2f1c32af54c3b46e7fadbf8006d8b9bcfbea535c316b0bd8813d6fb25e5d/ruff-0.14.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f53accc02ed2d200fa621593cdb3c1ae06aa9b2c3cae70bc96f72f0000ae97a9", size = 14284549, upload-time = "2025-11-28T20:55:06.08Z" }, + { url = "https://files.pythonhosted.org/packages/92/05/434ddd86becd64629c25fb6b4ce7637dd52a45cc4a4415a3008fe61c27b9/ruff-0.14.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:281f0e61a23fcdcffca210591f0f53aafaa15f9025b5b3f9706879aaa8683bc4", size = 14071389, upload-time = "2025-11-28T20:55:35.617Z" }, + { url = "https://files.pythonhosted.org/packages/ff/50/fdf89d4d80f7f9d4f420d26089a79b3bb1538fe44586b148451bc2ba8d9c/ruff-0.14.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:dbbaa5e14148965b91cb090236931182ee522a5fac9bc5575bafc5c07b9f9682", size = 14202679, upload-time = "2025-11-28T20:55:01.472Z" }, + { url = "https://files.pythonhosted.org/packages/77/54/87b34988984555425ce967f08a36df0ebd339bb5d9d0e92a47e41151eafc/ruff-0.14.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1464b6e54880c0fe2f2d6eaefb6db15373331414eddf89d6b903767ae2458143", size = 13147677, upload-time = "2025-11-28T20:55:19.933Z" }, + { url = "https://files.pythonhosted.org/packages/67/29/f55e4d44edfe053918a16a3299e758e1c18eef216b7a7092550d7a9ec51c/ruff-0.14.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f217ed871e4621ea6128460df57b19ce0580606c23aeab50f5de425d05226784", size = 13151392, upload-time = "2025-11-28T20:55:21.967Z" }, + { url = "https://files.pythonhosted.org/packages/36/69/47aae6dbd4f1d9b4f7085f4d9dcc84e04561ee7ad067bf52e0f9b02e3209/ruff-0.14.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6be02e849440ed3602d2eb478ff7ff07d53e3758f7948a2a598829660988619e", size = 13412230, upload-time = "2025-11-28T20:55:12.749Z" }, + { url = "https://files.pythonhosted.org/packages/b7/4b/6e96cb6ba297f2ba502a231cd732ed7c3de98b1a896671b932a5eefa3804/ruff-0.14.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:19a0f116ee5e2b468dfe80c41c84e2bbd6b74f7b719bee86c2ecde0a34563bcc", size = 14195397, upload-time = "2025-11-28T20:54:56.896Z" }, + { url = "https://files.pythonhosted.org/packages/69/82/251d5f1aa4dcad30aed491b4657cecd9fb4274214da6960ffec144c260f7/ruff-0.14.7-py3-none-win32.whl", hash = "sha256:e33052c9199b347c8937937163b9b149ef6ab2e4bb37b042e593da2e6f6cccfa", size = 13126751, upload-time = "2025-11-28T20:55:03.47Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b5/d0b7d145963136b564806f6584647af45ab98946660d399ec4da79cae036/ruff-0.14.7-py3-none-win_amd64.whl", hash = "sha256:e17a20ad0d3fad47a326d773a042b924d3ac31c6ca6deb6c72e9e6b5f661a7c6", size = 14531726, upload-time = "2025-11-28T20:54:59.121Z" }, + { url = "https://files.pythonhosted.org/packages/1d/d2/1637f4360ada6a368d3265bf39f2cf737a0aaab15ab520fc005903e883f8/ruff-0.14.7-py3-none-win_arm64.whl", hash = "sha256:be4d653d3bea1b19742fcc6502354e32f65cd61ff2fbdb365803ef2c2aec6228", size = 13609215, upload-time = "2025-11-28T20:55:15.375Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "skills-ref" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "click" }, + { name = "strictyaml" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "click", specifier = ">=8.0" }, + { name = "strictyaml", specifier = ">=1.7.3" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=7.0" }, + { name = "ruff", specifier = ">=0.8.0" }, +] + +[[package]] +name = "strictyaml" +version = "1.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/08/efd28d49162ce89c2ad61a88bd80e11fb77bc9f6c145402589112d38f8af/strictyaml-1.7.3.tar.gz", hash = "sha256:22f854a5fcab42b5ddba8030a0e4be51ca89af0267961c8d6cfa86395586c407", size = 115206, upload-time = "2023-03-10T12:50:27.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/7c/a81ef5ef10978dd073a854e0fa93b5d8021d0594b639cc8f6453c3c78a1d/strictyaml-1.7.3-py3-none-any.whl", hash = "sha256:fb5c8a4edb43bebb765959e420f9b3978d7f1af88c80606c03fb420888f5d1c7", size = 123917, upload-time = "2023-03-10T12:50:17.242Z" }, +]