Add skills-ref library (#2)

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 <available_skills> XML for agent prompts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Keith Lazuka
2025-12-18 09:39:21 -05:00
committed by GitHub
Unverified
parent fb08d608bc
commit 547831f3a2
18 changed files with 1610 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
# macOS
.DS_Store
# IDEs
.idea/
.vscode/
+22
View File
@@ -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/
+18
View File
@@ -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
```
+202
View File
@@ -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.
+85
View File
@@ -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 <available_skills> 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 `<available_skills>` 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
<available_skills>
<skill>
<name>
my-skill
</name>
<description>
What this skill does and when to use it
</description>
<location>
/path/to/my-skill/SKILL.md
</location>
</skill>
</available_skills>
```
The `<location>` element tells the agent where to find the full skill instructions.
## License
Apache 2.0
+30
View File
@@ -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",
]
+20
View File
@@ -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"
+105
View File
@@ -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 <available_skills> 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()
+25
View File
@@ -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]
+39
View File
@@ -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
+112
View File
@@ -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"),
)
+58
View File
@@ -0,0 +1,58 @@
"""Generate <available_skills> 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 <available_skills> 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 <available_skills> block containing each skill's
name, description, and location.
Example output:
<available_skills>
<skill>
<name>pdf-reader</name>
<description>Read and extract text from PDF files</description>
<location>/path/to/pdf-reader/SKILL.md</location>
</skill>
</available_skills>
"""
if not skill_dirs:
return "<available_skills>\n</available_skills>"
lines = ["<available_skills>"]
for skill_dir in skill_dirs:
skill_dir = Path(skill_dir).resolve()
props = read_properties(skill_dir)
lines.append("<skill>")
lines.append("<name>")
lines.append(html.escape(props.name))
lines.append("</name>")
lines.append("<description>")
lines.append(html.escape(props.description))
lines.append("</description>")
skill_md_path = find_skill_md(skill_dir)
lines.append("<location>")
lines.append(str(skill_md_path))
lines.append("</location>")
lines.append("</skill>")
lines.append("</available_skills>")
return "\n".join(lines)
+177
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""Tests for skills-ref library."""
+188
View File
@@ -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:*)"
+70
View File
@@ -0,0 +1,70 @@
"""Tests for prompt module."""
from skills_ref.prompt import to_prompt
def test_empty_list():
result = to_prompt([])
assert result == "<available_skills>\n</available_skills>"
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 "<available_skills>" in result
assert "</available_skills>" in result
assert "<name>\nmy-skill\n</name>" in result
assert "<description>\nA test skill\n</description>" in result
assert "<location>" 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("<skill>") == 2
assert result.count("</skill>") == 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 <foo> & <bar> tags
---
Body
""")
result = to_prompt([skill_dir])
assert "&lt;foo&gt;" in result
assert "&amp;" in result
assert "&lt;bar&gt;" in result
assert "<foo>" not in result
assert "<bar>" not in result
+290
View File
@@ -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}"
+162
View File
@@ -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" },
]