mirror of
https://github.com/agentskills/agentskills.git
synced 2026-06-18 15:54:06 +08:00
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:
committed by
GitHub
Unverified
parent
fb08d608bc
commit
547831f3a2
@@ -0,0 +1,6 @@
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# IDEs
|
||||
.idea/
|
||||
.vscode/
|
||||
@@ -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/
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for skills-ref library."""
|
||||
@@ -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:*)"
|
||||
@@ -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 "<foo>" in result
|
||||
assert "&" in result
|
||||
assert "<bar>" in result
|
||||
assert "<foo>" not in result
|
||||
assert "<bar>" not in result
|
||||
@@ -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}"
|
||||
Generated
+162
@@ -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" },
|
||||
]
|
||||
Reference in New Issue
Block a user