agentsquire 0.2.2__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,100 @@
1
+ """AgentSquire: ship agent integrations inside a Python package and install
2
+ them into whatever agent harness is present.
3
+
4
+ The full lifecycle is available as plain Python (REQ-16), no CLI required:
5
+ enumerate skills from a source, detect harnesses, then install / status /
6
+ update / uninstall. See docs/api.md for the reference.
7
+ """
8
+
9
+ __version__ = "0.2.2"
10
+
11
+ from agentsquire.harnesses import (
12
+ CLAUDE_CODE,
13
+ HERMES,
14
+ OPENCODE,
15
+ PI,
16
+ HarnessBackend,
17
+ HarnessNotDetectedError,
18
+ HarnessRegistry,
19
+ UnknownHarnessError,
20
+ UnsupportedScopeError,
21
+ default_registry,
22
+ )
23
+ from agentsquire.hashing import STAMP_KEY, skill_content_hash
24
+ from agentsquire.skills import (
25
+ InvalidSkillError,
26
+ Skill,
27
+ SkillViolation,
28
+ load_skill,
29
+ validate_skill_dir,
30
+ )
31
+ from agentsquire.sources import (
32
+ ENTRY_POINT_GROUP,
33
+ BundledPackageDataSource,
34
+ DirectorySource,
35
+ SkillSource,
36
+ SourceSkill,
37
+ )
38
+ from agentsquire.staleness import check_stale
39
+ from agentsquire.stamping import StampError, read_stamp
40
+ from agentsquire.verbs import (
41
+ InstalledSkill,
42
+ InstallResult,
43
+ RemovedSkill,
44
+ SkillState,
45
+ SkillStatus,
46
+ SkippedSkill,
47
+ UninstallResult,
48
+ UpdateResult,
49
+ install,
50
+ status,
51
+ uninstall,
52
+ update,
53
+ )
54
+
55
+ __all__ = [
56
+ "__version__",
57
+ # skill model
58
+ "Skill",
59
+ "SkillViolation",
60
+ "InvalidSkillError",
61
+ "validate_skill_dir",
62
+ "load_skill",
63
+ # hashing / provenance
64
+ "skill_content_hash",
65
+ "STAMP_KEY",
66
+ "read_stamp",
67
+ "StampError",
68
+ # sources (enumerate)
69
+ "SkillSource",
70
+ "SourceSkill",
71
+ "DirectorySource",
72
+ "BundledPackageDataSource",
73
+ "ENTRY_POINT_GROUP",
74
+ # harnesses (detect)
75
+ "HarnessBackend",
76
+ "HarnessRegistry",
77
+ "default_registry",
78
+ "UnknownHarnessError",
79
+ "HarnessNotDetectedError",
80
+ "UnsupportedScopeError",
81
+ "CLAUDE_CODE",
82
+ "PI",
83
+ "HERMES",
84
+ "OPENCODE",
85
+ # staleness hook
86
+ "check_stale",
87
+ # lifecycle verbs
88
+ "install",
89
+ "status",
90
+ "update",
91
+ "uninstall",
92
+ "SkillState",
93
+ "SkillStatus",
94
+ "InstalledSkill",
95
+ "SkippedSkill",
96
+ "RemovedSkill",
97
+ "InstallResult",
98
+ "UpdateResult",
99
+ "UninstallResult",
100
+ ]
agentsquire/cli.py ADDED
@@ -0,0 +1,198 @@
1
+ """Ready-made CLI subcommand group consumers mount into their own CLI.
2
+
3
+ One factory call — parameterized by (package, resource path, default scope) —
4
+ returns a click group with install/status/update/uninstall, so a consumer CLI
5
+ exposes e.g. ``<consumer> skills install`` with no glue code (REQ-15). The
6
+ group works identically mounted into a click app or a typer app via typer's
7
+ click compatibility. It carries no consumer-specific logic; everything the
8
+ consumer-specific comes in through the factory parameters.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import importlib
14
+ import importlib.metadata
15
+ from pathlib import Path
16
+
17
+ import click
18
+
19
+ from agentsquire.harnesses import (
20
+ SCOPES,
21
+ HarnessNotDetectedError,
22
+ UnknownHarnessError,
23
+ UnsupportedScopeError,
24
+ default_registry,
25
+ )
26
+ from agentsquire.roots import resolve_roots
27
+ from agentsquire.verbs import install as install_verb
28
+ from agentsquire.verbs import status as status_verb
29
+ from agentsquire.verbs import uninstall as uninstall_verb
30
+ from agentsquire.verbs import update as update_verb
31
+ from agentsquire.sources import BundledPackageDataSource
32
+
33
+
34
+ def _consumer_version(package: str, source_package: str) -> str:
35
+ try:
36
+ return importlib.metadata.version(source_package)
37
+ except importlib.metadata.PackageNotFoundError:
38
+ return getattr(importlib.import_module(package), "__version__", "unknown")
39
+
40
+
41
+ def skills_command_group(
42
+ package: str,
43
+ resource_path: str = "skills",
44
+ default_scope: str = "user",
45
+ *,
46
+ name: str = "skills",
47
+ source_package: str | None = None,
48
+ source_version: str | None = None,
49
+ home: Path | None = None,
50
+ project: Path | None = None,
51
+ ) -> click.Group:
52
+ """Build the mountable ``skills`` subcommand group for one consumer.
53
+
54
+ ``package`` is the consumer's importable package carrying skills as
55
+ package data under ``resource_path``; ``default_scope`` is the scope a
56
+ plain invocation uses (--scope overrides it, REQ-14). ``source_package``
57
+ and ``source_version`` default to the package name and its installed
58
+ distribution (or ``__version__``) and end up in the provenance stamp.
59
+ ``home`` and ``project`` point the group at explicit directories - tests
60
+ pass fixture paths; production omits them and each invocation resolves
61
+ the real ``Path.home()``/``Path.cwd()``.
62
+ """
63
+ source = BundledPackageDataSource(package, resource_path)
64
+ src_pkg = source_package or package
65
+ src_version = source_version or _consumer_version(package, src_pkg)
66
+
67
+ scope_option = click.option(
68
+ "--scope",
69
+ type=click.Choice(SCOPES),
70
+ default=default_scope,
71
+ show_default=True,
72
+ help="Which harness skills directory to operate on.",
73
+ )
74
+ harness_option = click.option(
75
+ "--harness",
76
+ default=None,
77
+ metavar="NAME",
78
+ help="Operate on one harness (default: all detected).",
79
+ )
80
+
81
+ def targets(harness: str | None):
82
+ """(backends, home, project) for this invocation, or a clear error."""
83
+ target_home, target_project = resolve_roots(home, project)
84
+ registry = default_registry()
85
+ if harness is not None:
86
+ try:
87
+ backends = [
88
+ registry.resolve(harness, home=target_home, project=target_project)
89
+ ]
90
+ except (UnknownHarnessError, HarnessNotDetectedError) as error:
91
+ raise click.ClickException(str(error)) from error
92
+ else:
93
+ backends = registry.detect(home=target_home, project=target_project)
94
+ if not backends:
95
+ raise click.ClickException("no supported harnesses detected")
96
+ return backends, target_home, target_project
97
+
98
+ def run_on(backend, harness, invoke_verb):
99
+ """One verb call on one backend. A scope the backend lacks is a clean
100
+ error when that harness was asked for, a named skip otherwise — never
101
+ an aborted multi-harness run."""
102
+ try:
103
+ return invoke_verb()
104
+ except UnsupportedScopeError as error:
105
+ if harness is not None:
106
+ raise click.ClickException(str(error)) from error
107
+ click.echo(f"skipped {backend.name}: {error}", err=True)
108
+ return None
109
+
110
+ group = click.Group(name=name, help="Manage this package's bundled agent skills.")
111
+
112
+ @group.command("install")
113
+ @scope_option
114
+ @harness_option
115
+ @click.pass_context
116
+ def install(ctx, scope, harness):
117
+ """Install bundled skills into detected harnesses."""
118
+ backends, home, project = targets(harness)
119
+ failed = False
120
+ for backend in backends:
121
+ result = run_on(backend, harness, lambda: install_verb(
122
+ source, backend, scope=scope, home=home, project=project,
123
+ source_package=src_pkg, source_version=src_version,
124
+ ))
125
+ if result is None:
126
+ continue
127
+ for skill in result.installed:
128
+ click.echo(f"installed {skill.name} -> {skill.path}")
129
+ for skill in result.up_to_date:
130
+ click.echo(f"up-to-date {skill.name} ({backend.name}/{scope})")
131
+ for skill in result.skipped:
132
+ click.echo(f"skipped {skill.name}: {skill.reason}", err=True)
133
+ for violation in result.rejected:
134
+ click.echo(f"invalid skill {violation.message}", err=True)
135
+ failed = failed or not result.ok
136
+ if failed:
137
+ ctx.exit(1)
138
+
139
+ @group.command("status")
140
+ @scope_option
141
+ @harness_option
142
+ def status(scope, harness):
143
+ """Show each bundled skill's state per harness."""
144
+ backends, home, project = targets(harness)
145
+ for backend in backends:
146
+ statuses = run_on(backend, harness, lambda: status_verb(
147
+ source, backend, scope=scope, home=home, project=project
148
+ ))
149
+ for skill in statuses or ():
150
+ click.echo(f"{skill.state.value} {skill.name} ({backend.name}/{scope})")
151
+
152
+ @group.command("update")
153
+ @scope_option
154
+ @harness_option
155
+ @click.option("--force", is_flag=True, help="Overwrite locally-modified installs.")
156
+ @click.pass_context
157
+ def update(ctx, scope, harness, force):
158
+ """Update stale installed skills to the bundled version."""
159
+ backends, home, project = targets(harness)
160
+ failed = False
161
+ for backend in backends:
162
+ result = run_on(backend, harness, lambda: update_verb(
163
+ source, backend, scope=scope, home=home, project=project,
164
+ source_package=src_pkg, source_version=src_version, force=force,
165
+ ))
166
+ if result is None:
167
+ continue
168
+ for skill in result.updated:
169
+ click.echo(f"updated {skill.name} -> {skill.path}")
170
+ for skill in result.up_to_date:
171
+ click.echo(f"up-to-date {skill.name} ({backend.name}/{scope})")
172
+ for skill in result.skipped:
173
+ click.echo(f"skipped {skill.name}: {skill.reason}", err=True)
174
+ for violation in result.rejected:
175
+ click.echo(f"invalid skill {violation.message}", err=True)
176
+ failed = failed or not result.ok
177
+ if failed:
178
+ ctx.exit(1)
179
+
180
+ @group.command("uninstall")
181
+ @scope_option
182
+ @harness_option
183
+ def uninstall(scope, harness):
184
+ """Remove installed skills this package's stamp owns."""
185
+ backends, home, project = targets(harness)
186
+ for backend in backends:
187
+ result = run_on(backend, harness, lambda: uninstall_verb(
188
+ source, backend, scope=scope, home=home, project=project,
189
+ source_package=src_pkg,
190
+ ))
191
+ if result is None:
192
+ continue
193
+ for skill in result.removed:
194
+ click.echo(f"removed {skill.name} ({backend.name}/{scope})")
195
+ for skill in result.skipped:
196
+ click.echo(f"skipped {skill.name}: {skill.reason}", err=True)
197
+
198
+ return group
@@ -0,0 +1,141 @@
1
+ """Harness backends and registry.
2
+
3
+ Each supported harness gets one backend recording where it keeps skills per
4
+ scope and which marker directories signal its presence. Directory values
5
+ mirror docs/harnesses.md (REQ-06) — change the document first, then the
6
+ backend. Adding harness N+1 means registering one more backend; the verbs
7
+ never change (REQ-05).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ SCOPES = ("user", "project")
16
+
17
+
18
+ class UnknownHarnessError(Exception):
19
+ """An unsupported harness was named; the message lists the supported set."""
20
+
21
+ def __init__(self, name: str, supported: list[str]):
22
+ self.name = name
23
+ self.supported = supported
24
+ super().__init__(
25
+ f"unknown harness {name!r}; supported harnesses: {', '.join(supported)}"
26
+ )
27
+
28
+
29
+ class HarnessNotDetectedError(Exception):
30
+ """A supported harness was named but is not present in this environment."""
31
+
32
+ def __init__(self, name: str):
33
+ self.name = name
34
+ super().__init__(
35
+ f"harness {name!r} is supported but not detected on this machine or in this project"
36
+ )
37
+
38
+
39
+ class UnsupportedScopeError(Exception):
40
+ """The harness has no skills directory for the requested scope."""
41
+
42
+ def __init__(self, name: str, scope: str):
43
+ self.name = name
44
+ self.scope = scope
45
+ super().__init__(f"harness {name!r} has no {scope}-scope skills directory")
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class HarnessBackend:
50
+ """One harness: skill directories per scope (relative to the scope root)
51
+ and marker directories whose presence means the harness is in use."""
52
+
53
+ name: str
54
+ user_skills_dir: str | None = None
55
+ project_skills_dir: str | None = None
56
+ user_marker_dirs: tuple[str, ...] = ()
57
+ project_marker_dirs: tuple[str, ...] = ()
58
+
59
+ def skills_dir(self, scope: str, *, home: Path, project: Path) -> Path:
60
+ if scope not in SCOPES:
61
+ raise ValueError(f"unknown scope {scope!r}; expected one of {SCOPES}")
62
+ relative = self.user_skills_dir if scope == "user" else self.project_skills_dir
63
+ if relative is None:
64
+ raise UnsupportedScopeError(self.name, scope)
65
+ root = home if scope == "user" else project
66
+ return root / relative
67
+
68
+ def detect(self, *, home: Path, project: Path) -> bool:
69
+ return any((home / marker).is_dir() for marker in self.user_marker_dirs) or any(
70
+ (project / marker).is_dir() for marker in self.project_marker_dirs
71
+ )
72
+
73
+
74
+ class HarnessRegistry:
75
+ """Registered backends: detection across them, resolution by name."""
76
+
77
+ def __init__(self):
78
+ self._backends: dict[str, HarnessBackend] = {}
79
+
80
+ def register(self, backend: HarnessBackend) -> None:
81
+ self._backends[backend.name] = backend
82
+
83
+ def names(self) -> list[str]:
84
+ return list(self._backends)
85
+
86
+ def detect(self, *, home: Path, project: Path) -> list[HarnessBackend]:
87
+ return [
88
+ backend
89
+ for backend in self._backends.values()
90
+ if backend.detect(home=home, project=project)
91
+ ]
92
+
93
+ def resolve(self, name: str, *, home: Path, project: Path) -> HarnessBackend:
94
+ backend = self._backends.get(name)
95
+ if backend is None:
96
+ raise UnknownHarnessError(name, self.names())
97
+ if not backend.detect(home=home, project=project):
98
+ raise HarnessNotDetectedError(name)
99
+ return backend
100
+
101
+
102
+ CLAUDE_CODE = HarnessBackend(
103
+ name="claude-code",
104
+ user_skills_dir=".claude/skills",
105
+ project_skills_dir=".claude/skills",
106
+ user_marker_dirs=(".claude",),
107
+ project_marker_dirs=(".claude",),
108
+ )
109
+
110
+ PI = HarnessBackend(
111
+ name="pi",
112
+ user_skills_dir=".pi/agent/skills",
113
+ project_skills_dir=".pi/skills",
114
+ user_marker_dirs=(".pi",),
115
+ project_marker_dirs=(".pi",),
116
+ )
117
+
118
+ # Hermes has no per-project skills directory; project scope errors (see doc).
119
+ HERMES = HarnessBackend(
120
+ name="hermes",
121
+ user_skills_dir=".hermes/skills",
122
+ user_marker_dirs=(".hermes",),
123
+ )
124
+
125
+ OPENCODE = HarnessBackend(
126
+ name="opencode",
127
+ user_skills_dir=".config/opencode/skills",
128
+ project_skills_dir=".opencode/skills",
129
+ user_marker_dirs=(".config/opencode", ".opencode"),
130
+ project_marker_dirs=(".opencode",),
131
+ )
132
+
133
+
134
+ def default_registry() -> HarnessRegistry:
135
+ """A fresh registry with the launch backends registered."""
136
+ registry = HarnessRegistry()
137
+ registry.register(CLAUDE_CODE)
138
+ registry.register(PI)
139
+ registry.register(HERMES)
140
+ registry.register(OPENCODE)
141
+ return registry
agentsquire/hashing.py ADDED
@@ -0,0 +1,59 @@
1
+ """Deterministic content hash over a skill directory.
2
+
3
+ The hash covers every file under the skill directory, sorted by relative path
4
+ so filesystem ordering never matters. SKILL.md is hashed in canonical form —
5
+ frontmatter minus the provenance stamp (``metadata.agentsquire``), dumped with
6
+ sorted keys, plus the body — so a freshly stamped install hashes back to its
7
+ own stamped value even though stamping rewrites the frontmatter bytes.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ from pathlib import Path
14
+
15
+ import yaml
16
+
17
+ # The provenance stamp lives under this key in the SKILL.md frontmatter
18
+ # "metadata" map (spec-legal free-form map per agentskills.io).
19
+ STAMP_KEY = "agentsquire"
20
+
21
+
22
+ def _canonical_skill_md(text: str) -> bytes:
23
+ """SKILL.md content with the stamp stripped and frontmatter canonicalized."""
24
+ if not text.startswith("---"):
25
+ return text.encode()
26
+ parts = text.split("---", 2)
27
+ if len(parts) < 3:
28
+ return text.encode()
29
+ try:
30
+ frontmatter = yaml.safe_load(parts[1])
31
+ except yaml.YAMLError:
32
+ return text.encode()
33
+ if not isinstance(frontmatter, dict):
34
+ return text.encode()
35
+
36
+ metadata = frontmatter.get("metadata")
37
+ if isinstance(metadata, dict):
38
+ metadata.pop(STAMP_KEY, None)
39
+ if not metadata and "metadata" in frontmatter:
40
+ del frontmatter["metadata"]
41
+
42
+ canonical_fm = yaml.safe_dump(frontmatter, sort_keys=True)
43
+ return f"---\n{canonical_fm}---{parts[2]}".encode()
44
+
45
+
46
+ def skill_content_hash(path: Path) -> str:
47
+ """Deterministic hash of a skill directory, excluding the provenance stamp."""
48
+ digest = hashlib.sha256()
49
+ for file in sorted(p for p in path.rglob("*") if p.is_file()):
50
+ relpath = file.relative_to(path).as_posix()
51
+ if relpath == "SKILL.md":
52
+ content = _canonical_skill_md(file.read_text())
53
+ else:
54
+ content = file.read_bytes()
55
+ digest.update(relpath.encode())
56
+ digest.update(b"\0")
57
+ digest.update(hashlib.sha256(content).digest())
58
+ digest.update(b"\0")
59
+ return f"sha256:{digest.hexdigest()}"
agentsquire/py.typed ADDED
File without changes
agentsquire/roots.py ADDED
@@ -0,0 +1,50 @@
1
+ """Resolution of the home and project roots, with env-var overrides.
2
+
3
+ Explicit ``home=`` / ``project=`` arguments always win. When they are omitted
4
+ (the production wiring, where each invocation must resolve the real roots),
5
+ ``AGENTSQUIRE_HOME`` / ``AGENTSQUIRE_PROJECT`` redirect the roots if set to a
6
+ non-empty value; otherwise the real ``Path.home()`` / ``Path.cwd()`` are used.
7
+
8
+ The env overrides exist so a consumer's CLI-level test can point the wired
9
+ staleness hook and the mounted ``skills`` subcommands at fixture directories by
10
+ setting two variables, instead of monkeypatching ``Path.home`` and chdir. An
11
+ empty-string value is treated as unset - the NO_COLOR convention already used
12
+ for ``CI`` and ``AGENTSQUIRE_NO_UPDATE_CHECK``.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ from pathlib import Path
19
+
20
+ HOME_ENV = "AGENTSQUIRE_HOME"
21
+ PROJECT_ENV = "AGENTSQUIRE_PROJECT"
22
+
23
+
24
+ def resolve_home(home: Path | None) -> Path:
25
+ """The home root: explicit ``home`` if given, else ``$AGENTSQUIRE_HOME``
26
+ (non-empty), else the real ``Path.home()``."""
27
+ if home is not None:
28
+ return home
29
+ override = os.environ.get(HOME_ENV)
30
+ if override:
31
+ return Path(override)
32
+ return Path.home()
33
+
34
+
35
+ def resolve_project(project: Path | None) -> Path:
36
+ """The project root: explicit ``project`` if given, else
37
+ ``$AGENTSQUIRE_PROJECT`` (non-empty), else the real ``Path.cwd()``."""
38
+ if project is not None:
39
+ return project
40
+ override = os.environ.get(PROJECT_ENV)
41
+ if override:
42
+ return Path(override)
43
+ return Path.cwd()
44
+
45
+
46
+ def resolve_roots(
47
+ home: Path | None, project: Path | None
48
+ ) -> tuple[Path, Path]:
49
+ """``(home, project)`` roots with explicit args winning over env over real."""
50
+ return resolve_home(home), resolve_project(project)
agentsquire/skills.py ADDED
@@ -0,0 +1,95 @@
1
+ """Skill model and agentskills.io structural validation.
2
+
3
+ A skill is a directory containing a SKILL.md whose YAML frontmatter carries at
4
+ least ``name`` (equal to the directory name) and ``description``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+
12
+ import yaml
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Skill:
17
+ """A parsed, structurally valid skill directory."""
18
+
19
+ name: str
20
+ description: str
21
+ path: Path
22
+ frontmatter: dict = field(compare=False)
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class SkillViolation:
27
+ """One broken structural rule in one skill directory."""
28
+
29
+ skill: str
30
+ rule: str
31
+ message: str
32
+
33
+
34
+ class InvalidSkillError(Exception):
35
+ """Raised by load_skill when a skill directory breaks structural rules."""
36
+
37
+ def __init__(self, violations: list[SkillViolation]):
38
+ self.violations = violations
39
+ super().__init__("; ".join(v.message for v in violations))
40
+
41
+
42
+ def _parse_frontmatter(text: str) -> dict | None:
43
+ """Return the YAML frontmatter mapping, or None if absent/malformed."""
44
+ if not text.startswith("---"):
45
+ return None
46
+ parts = text.split("---", 2)
47
+ if len(parts) < 3:
48
+ return None
49
+ try:
50
+ data = yaml.safe_load(parts[1])
51
+ except yaml.YAMLError:
52
+ return None
53
+ return data if isinstance(data, dict) else None
54
+
55
+
56
+ def validate_skill_dir(path: Path) -> list[SkillViolation]:
57
+ """Check one skill directory against the structural rules; [] means valid."""
58
+ skill = path.name
59
+
60
+ def violation(rule: str, message: str) -> SkillViolation:
61
+ return SkillViolation(skill=skill, rule=rule, message=f"{skill}: {message}")
62
+
63
+ manifest = path / "SKILL.md"
64
+ if not manifest.is_file():
65
+ return [violation("missing-skill-md", "no SKILL.md in skill directory")]
66
+
67
+ frontmatter = _parse_frontmatter(manifest.read_text())
68
+ if frontmatter is None:
69
+ return [violation("invalid-frontmatter", "SKILL.md has no parseable YAML frontmatter")]
70
+
71
+ violations = []
72
+ name = frontmatter.get("name")
73
+ if not name:
74
+ violations.append(violation("missing-name", "frontmatter has no name"))
75
+ elif name != skill:
76
+ violations.append(
77
+ violation("name-mismatch", f"frontmatter name {name!r} != directory name")
78
+ )
79
+ if not frontmatter.get("description"):
80
+ violations.append(violation("missing-description", "frontmatter has no description"))
81
+ return violations
82
+
83
+
84
+ def load_skill(path: Path) -> Skill:
85
+ """Parse a valid skill directory into a Skill; raise InvalidSkillError otherwise."""
86
+ violations = validate_skill_dir(path)
87
+ if violations:
88
+ raise InvalidSkillError(violations)
89
+ frontmatter = _parse_frontmatter((path / "SKILL.md").read_text())
90
+ return Skill(
91
+ name=frontmatter["name"],
92
+ description=frontmatter["description"],
93
+ path=path,
94
+ frontmatter=frontmatter,
95
+ )