abstractskill 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,33 @@
1
+ """AbstractSkill — Agent Skills parsing and discovery for AbstractFramework."""
2
+
3
+ from abstractskill.errors import (
4
+ SkillError,
5
+ SkillNotFoundError,
6
+ SkillParseError,
7
+ SkillValidationError,
8
+ )
9
+ from abstractskill.hash import content_hash
10
+ from abstractskill.loader import FilesystemSkillLoader
11
+ from abstractskill.models import LoadedSkill, SkillDocument, SkillMetadata
12
+ from abstractskill.parser import parse_skill_md
13
+ from abstractskill.prompt import format_available_skills_xml
14
+ from abstractskill.validation import SKILL_FILENAME, SKILL_NAME_RE, validate_skill_name
15
+
16
+ __all__ = [
17
+ "FilesystemSkillLoader",
18
+ "LoadedSkill",
19
+ "SKILL_FILENAME",
20
+ "SKILL_NAME_RE",
21
+ "SkillDocument",
22
+ "SkillError",
23
+ "SkillMetadata",
24
+ "SkillNotFoundError",
25
+ "SkillParseError",
26
+ "SkillValidationError",
27
+ "content_hash",
28
+ "format_available_skills_xml",
29
+ "parse_skill_md",
30
+ "validate_skill_name",
31
+ ]
32
+
33
+ __version__ = "0.1.0"
@@ -0,0 +1,17 @@
1
+ """Skill-specific exceptions."""
2
+
3
+
4
+ class SkillError(Exception):
5
+ """Base error for AbstractSkill operations."""
6
+
7
+
8
+ class SkillParseError(SkillError):
9
+ """Raised when SKILL.md content cannot be parsed."""
10
+
11
+
12
+ class SkillValidationError(SkillError):
13
+ """Raised when a skill document or directory layout is invalid."""
14
+
15
+
16
+ class SkillNotFoundError(SkillError):
17
+ """Raised when a requested skill cannot be resolved."""
abstractskill/hash.py ADDED
@@ -0,0 +1,14 @@
1
+ """Content hashing for skill evolution tracking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+
7
+
8
+ def content_hash(content: str | bytes) -> str:
9
+ """Return a stable SHA-256 hex digest for skill snapshotting."""
10
+ if isinstance(content, str):
11
+ payload = content.encode("utf-8")
12
+ else:
13
+ payload = content
14
+ return hashlib.sha256(payload).hexdigest()
@@ -0,0 +1,75 @@
1
+ """Filesystem discovery and loading for Agent Skills."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from abstractskill.errors import SkillNotFoundError, SkillParseError, SkillValidationError
8
+ from abstractskill.models import LoadedSkill, SkillMetadata
9
+ from abstractskill.parser import parse_skill_md
10
+ from abstractskill.validation import SKILL_FILENAME, validate_directory_name_match
11
+
12
+
13
+ class FilesystemSkillLoader:
14
+ """Discover and load skills from one or more directory roots.
15
+
16
+ Later roots override earlier ones when skill names collide.
17
+ """
18
+
19
+ def __init__(self, roots: Path | str | list[Path | str]) -> None:
20
+ if isinstance(roots, (str, Path)):
21
+ root_list = [roots]
22
+ else:
23
+ root_list = list(roots)
24
+ self._roots = [Path(root).expanduser() for root in root_list]
25
+
26
+ @property
27
+ def roots(self) -> tuple[Path, ...]:
28
+ return tuple(self._roots)
29
+
30
+ def discover(self) -> list[SkillMetadata]:
31
+ """Return metadata for all valid skills, sorted by name."""
32
+ by_name: dict[str, SkillMetadata] = {}
33
+ for root in self._roots:
34
+ if not root.is_dir():
35
+ continue
36
+ for child in sorted(root.iterdir()):
37
+ if not child.is_dir():
38
+ continue
39
+ skill_file = child / SKILL_FILENAME
40
+ if not skill_file.is_file():
41
+ continue
42
+ try:
43
+ document = parse_skill_md(
44
+ skill_file.read_text(encoding="utf-8"),
45
+ source_path=skill_file,
46
+ directory_name=child.name,
47
+ )
48
+ except (SkillParseError, SkillValidationError):
49
+ continue
50
+ by_name[document.metadata.name] = document.metadata
51
+ return [by_name[name] for name in sorted(by_name)]
52
+
53
+ def load(self, name: str) -> LoadedSkill:
54
+ """Load the full SKILL.md document for a skill by name."""
55
+ for root in reversed(self._roots):
56
+ if not root.is_dir():
57
+ continue
58
+ candidate = root / name
59
+ skill_file = candidate / SKILL_FILENAME
60
+ if not skill_file.is_file():
61
+ continue
62
+ text = skill_file.read_text(encoding="utf-8")
63
+ document = parse_skill_md(
64
+ text,
65
+ source_path=skill_file,
66
+ directory_name=candidate.name,
67
+ )
68
+ if document.metadata.name != name:
69
+ raise SkillValidationError(
70
+ f"skill at {skill_file} declares name {document.metadata.name!r}, "
71
+ f"expected {name!r}"
72
+ )
73
+ validate_directory_name_match(document.metadata.name, candidate.name)
74
+ return LoadedSkill(document=document, root_dir=candidate)
75
+ raise SkillNotFoundError(f"skill not found: {name}")
@@ -0,0 +1,59 @@
1
+ """Data models for Agent Skills."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any, Mapping
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class SkillMetadata:
12
+ """Metadata-only skill view used for discovery and progressive disclosure."""
13
+
14
+ name: str
15
+ description: str
16
+ license: str | None = None
17
+ compatibility: str | None = None
18
+ allowed_tools: tuple[str, ...] = ()
19
+ metadata: Mapping[str, Any] = field(default_factory=dict)
20
+ source_path: Path | None = None
21
+
22
+ def to_dict(self) -> dict[str, Any]:
23
+ payload: dict[str, Any] = {
24
+ "name": self.name,
25
+ "description": self.description,
26
+ }
27
+ if self.license is not None:
28
+ payload["license"] = self.license
29
+ if self.compatibility is not None:
30
+ payload["compatibility"] = self.compatibility
31
+ if self.allowed_tools:
32
+ payload["allowed_tools"] = list(self.allowed_tools)
33
+ if self.metadata:
34
+ payload["metadata"] = dict(self.metadata)
35
+ if self.source_path is not None:
36
+ payload["source_path"] = str(self.source_path)
37
+ return payload
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class SkillDocument:
42
+ """Full SKILL.md content: frontmatter metadata plus markdown instructions."""
43
+
44
+ metadata: SkillMetadata
45
+ body: str
46
+ raw: str
47
+ content_hash: str
48
+
49
+ @property
50
+ def name(self) -> str:
51
+ return self.metadata.name
52
+
53
+
54
+ @dataclass(frozen=True, slots=True)
55
+ class LoadedSkill:
56
+ """A skill resolved from disk with its root directory."""
57
+
58
+ document: SkillDocument
59
+ root_dir: Path
@@ -0,0 +1,117 @@
1
+ """SKILL.md parsing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any, Mapping
7
+
8
+ import yaml
9
+
10
+ from abstractskill.errors import SkillParseError, SkillValidationError
11
+ from abstractskill.hash import content_hash
12
+ from abstractskill.models import SkillDocument, SkillMetadata
13
+ from abstractskill.validation import validate_skill_name
14
+
15
+ _FRONTMATTER_DELIM = "---"
16
+
17
+
18
+ def _split_frontmatter(text: str) -> tuple[Mapping[str, Any], str]:
19
+ stripped = text.lstrip("\ufeff")
20
+ if not stripped.startswith(f"{_FRONTMATTER_DELIM}\n"):
21
+ raise SkillParseError("SKILL.md must start with YAML frontmatter")
22
+
23
+ lines = stripped.splitlines()
24
+ if not lines or lines[0].strip() != _FRONTMATTER_DELIM:
25
+ raise SkillParseError("SKILL.md frontmatter opening delimiter is missing")
26
+
27
+ closing_idx = None
28
+ for idx in range(1, len(lines)):
29
+ if lines[idx].strip() == _FRONTMATTER_DELIM:
30
+ closing_idx = idx
31
+ break
32
+ if closing_idx is None:
33
+ raise SkillParseError("SKILL.md frontmatter closing delimiter is missing")
34
+
35
+ frontmatter_text = "\n".join(lines[1:closing_idx])
36
+ body = "\n".join(lines[closing_idx + 1 :])
37
+ if body.startswith("\n"):
38
+ body = body[1:]
39
+
40
+ try:
41
+ loaded = yaml.safe_load(frontmatter_text) or {}
42
+ except yaml.YAMLError as exc:
43
+ raise SkillParseError(f"invalid YAML frontmatter: {exc}") from exc
44
+
45
+ if not isinstance(loaded, dict):
46
+ raise SkillParseError("SKILL.md frontmatter must be a mapping")
47
+ return loaded, body
48
+
49
+
50
+ def _parse_allowed_tools(raw: Any) -> tuple[str, ...]:
51
+ if raw is None:
52
+ return ()
53
+ if isinstance(raw, str):
54
+ tokens = raw.split()
55
+ elif isinstance(raw, list):
56
+ tokens = [str(item).strip() for item in raw if str(item).strip()]
57
+ else:
58
+ raise SkillValidationError("allowed-tools must be a string or list of strings")
59
+ return tuple(tokens)
60
+
61
+
62
+ def _metadata_from_frontmatter(
63
+ frontmatter: Mapping[str, Any],
64
+ *,
65
+ source_path: Path | None = None,
66
+ ) -> SkillMetadata:
67
+ if "name" not in frontmatter:
68
+ raise SkillValidationError("SKILL.md frontmatter requires 'name'")
69
+ if "description" not in frontmatter:
70
+ raise SkillValidationError("SKILL.md frontmatter requires 'description'")
71
+
72
+ name = validate_skill_name(str(frontmatter["name"]))
73
+ description = str(frontmatter["description"]).strip()
74
+ if not description:
75
+ raise SkillValidationError("SKILL.md frontmatter requires a non-empty description")
76
+
77
+ license_value = frontmatter.get("license")
78
+ compatibility = frontmatter.get("compatibility")
79
+ extra = frontmatter.get("metadata")
80
+ if extra is not None and not isinstance(extra, dict):
81
+ raise SkillValidationError("metadata frontmatter field must be a mapping")
82
+
83
+ allowed_raw = frontmatter.get("allowed-tools", frontmatter.get("allowed_tools"))
84
+ allowed_tools = _parse_allowed_tools(allowed_raw)
85
+
86
+ return SkillMetadata(
87
+ name=name,
88
+ description=description,
89
+ license=str(license_value).strip() if license_value is not None else None,
90
+ compatibility=str(compatibility).strip() if compatibility is not None else None,
91
+ allowed_tools=allowed_tools,
92
+ metadata=dict(extra or {}),
93
+ source_path=source_path,
94
+ )
95
+
96
+
97
+ def parse_skill_md(
98
+ text: str,
99
+ *,
100
+ source_path: Path | None = None,
101
+ directory_name: str | None = None,
102
+ ) -> SkillDocument:
103
+ """Parse SKILL.md text into metadata and instructions."""
104
+ frontmatter, body = _split_frontmatter(text)
105
+ metadata = _metadata_from_frontmatter(frontmatter, source_path=source_path)
106
+
107
+ if directory_name is not None:
108
+ from abstractskill.validation import validate_directory_name_match
109
+
110
+ validate_directory_name_match(metadata.name, directory_name)
111
+
112
+ return SkillDocument(
113
+ metadata=metadata,
114
+ body=body,
115
+ raw=text,
116
+ content_hash=content_hash(text),
117
+ )
@@ -0,0 +1,22 @@
1
+ """Prompt helpers for progressive skill disclosure."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+
7
+ from abstractskill.models import SkillMetadata
8
+
9
+
10
+ def format_available_skills_xml(skills: list[SkillMetadata]) -> str:
11
+ """Render a deterministic `<available_skills>` block for host prompts."""
12
+ if not skills:
13
+ return "<available_skills></available_skills>"
14
+
15
+ lines = ["<available_skills>"]
16
+ for skill in sorted(skills, key=lambda item: item.name):
17
+ lines.append(" <skill>")
18
+ lines.append(f" <name>{html.escape(skill.name)}</name>")
19
+ lines.append(f" <description>{html.escape(skill.description)}</description>")
20
+ lines.append(" </skill>")
21
+ lines.append("</available_skills>")
22
+ return "\n".join(lines)
@@ -0,0 +1,34 @@
1
+ """Validation helpers for Agent Skills."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from abstractskill.errors import SkillValidationError
8
+
9
+ SKILL_NAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$")
10
+ SKILL_FILENAME = "SKILL.md"
11
+
12
+
13
+ def validate_skill_name(name: str) -> str:
14
+ """Validate an Agent Skills `name` field."""
15
+ if not isinstance(name, str):
16
+ raise SkillValidationError("skill name must be a string")
17
+ normalized = name.strip()
18
+ if not normalized:
19
+ raise SkillValidationError("skill name is required")
20
+ if len(normalized) > 64:
21
+ raise SkillValidationError("skill name must be at most 64 characters")
22
+ if not SKILL_NAME_RE.fullmatch(normalized):
23
+ raise SkillValidationError(
24
+ "skill name must use lowercase letters, digits, and hyphens only"
25
+ )
26
+ return normalized
27
+
28
+
29
+ def validate_directory_name_match(name: str, directory_name: str) -> None:
30
+ """Ensure the skill name matches its leaf directory name."""
31
+ if name != directory_name:
32
+ raise SkillValidationError(
33
+ f"skill name {name!r} must match directory name {directory_name!r}"
34
+ )
@@ -0,0 +1,96 @@
1
+ Metadata-Version: 2.4
2
+ Name: abstractskill
3
+ Version: 0.1.0
4
+ Summary: Agent Skills (SKILL.md) parsing, discovery, and evolution tracking for AbstractFramework
5
+ Author-email: Laurent-Philippe Albou <contact@abstractcore.ai>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/lpalbou/AbstractSkill
8
+ Project-URL: Repository, https://github.com/lpalbou/AbstractSkill
9
+ Project-URL: Documentation, https://github.com/lpalbou/AbstractSkill#readme
10
+ Keywords: abstractframework,abstractcore,abstractgateway,agent-skills,skill-md,skills
11
+ Classifier: Development Status :: 2 - Pre-Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: PyYAML>=6.0
24
+ Provides-Extra: apple
25
+ Provides-Extra: all-apple
26
+ Provides-Extra: gpu
27
+ Provides-Extra: all-gpu
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
30
+ Provides-Extra: test
31
+ Requires-Dist: pytest>=7.0.0; extra == "test"
32
+ Dynamic: license-file
33
+
34
+ # AbstractSkill
35
+
36
+ AbstractSkill is the shared Python library for [Agent Skills](https://agentskills.io/) (`SKILL.md`) in the
37
+ [AbstractFramework](https://github.com/lpalbou/AbstractFramework) ecosystem.
38
+
39
+ It provides a small, dependency-light foundation for:
40
+
41
+ - parsing and validating `SKILL.md` frontmatter and instructions
42
+ - discovering skills on disk (progressive disclosure: metadata first)
43
+ - computing stable content hashes for skill evolution and replay safety
44
+ - formatting compact `<available_skills>` prompt blocks for hosts and agents
45
+
46
+ Flows run; skills are activated. AbstractSkill owns the portable skill contract so `abstractruntime`,
47
+ `abstractgateway`, and thin clients can share identical semantics without duplicating parsers.
48
+
49
+ ## Install
50
+
51
+ ```bash
52
+ pip install abstractskill
53
+ ```
54
+
55
+ ## Quick start
56
+
57
+ ```python
58
+ from pathlib import Path
59
+
60
+ from abstractskill import FilesystemSkillLoader, format_available_skills_xml, parse_skill_md
61
+
62
+ # Parse a SKILL.md file
63
+ doc = parse_skill_md(Path("my-skill/SKILL.md").read_text(encoding="utf-8"))
64
+ print(doc.metadata.name, doc.metadata.description)
65
+
66
+ # Discover skills under one or more roots (later roots override earlier ones)
67
+ loader = FilesystemSkillLoader([Path.home() / ".abstract" / "skills", Path(".abstract/skills")])
68
+ skills = loader.discover()
69
+ print(format_available_skills_xml(skills))
70
+
71
+ # Load full instructions when a skill is activated
72
+ loaded = loader.load("my-skill")
73
+ print(loaded.document.content_hash)
74
+ ```
75
+
76
+ ## Package scope (v0.1.0)
77
+
78
+ - `parse_skill_md` — YAML frontmatter + markdown body
79
+ - `FilesystemSkillLoader` — list metadata and load full documents
80
+ - `content_hash` — SHA-256 digest for evolution tracking
81
+ - `format_available_skills_xml` — deterministic discovery prompt block
82
+
83
+ Out of scope for this release: gateway registry APIs, zip `.skill` packaging, and runtime activation handlers.
84
+ Those layers live in `abstractgateway` and `abstractruntime` and consume this library.
85
+
86
+ ## Development
87
+
88
+ ```bash
89
+ python -m pip install -e ".[test]"
90
+ python -m pytest -q
91
+ python -m build
92
+ ```
93
+
94
+ ## License
95
+
96
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,13 @@
1
+ abstractskill/__init__.py,sha256=Zs5siT-k1cqlcCpAnDwNCHo8xySrCYyD91G8Fkqww5g,948
2
+ abstractskill/errors.py,sha256=I3e8uYZ8kOvczbFERNf6fz3vf0hTubLL85wti0JKktw,422
3
+ abstractskill/hash.py,sha256=U2jfSgD1VxnP_wvNiiwhfRfrjf_rYGuSwik0JEXN7ZM,380
4
+ abstractskill/loader.py,sha256=3bIWyhQ8KPsqb-BZBL5hPTgY7tBwIqx51E3hWzaFpiQ,2940
5
+ abstractskill/models.py,sha256=0q87x3lkvfGawFKX0Bt0D9beyQnoRJkeGbv-BZkulB8,1662
6
+ abstractskill/parser.py,sha256=ZhSnz6xnZBdbHq0tcNphNBz0XzLMhhh_O3tFkwAVqJ0,3967
7
+ abstractskill/prompt.py,sha256=wqHvIczlh9xNlkyvjYReK5RRLDexIRSs5xuJY9Czq2E,766
8
+ abstractskill/validation.py,sha256=2ZX-DlihZnKWMqjudemSB2CQlNAXp7vxdFqiyy1sTac,1153
9
+ abstractskill-0.1.0.dist-info/licenses/LICENSE,sha256=z4XcvE7hBF5qzbKZSumgg2ha3EO1Fsd06a34e7wnmXM,1105
10
+ abstractskill-0.1.0.dist-info/METADATA,sha256=AMzYrtIsuE0Ale9mijie37QysvetZ9tZdyYZ2IW3z-k,3420
11
+ abstractskill-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
12
+ abstractskill-0.1.0.dist-info/top_level.txt,sha256=GR2MEbgAXsbQcdZusmHcaRv_JpWjyeYbLGzMTHfGQhY,14
13
+ abstractskill-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Laurent-Philippe Albou <contact@abstractcore.ai>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ abstractskill