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.
- agentsquire/__init__.py +100 -0
- agentsquire/cli.py +198 -0
- agentsquire/harnesses.py +141 -0
- agentsquire/hashing.py +59 -0
- agentsquire/py.typed +0 -0
- agentsquire/roots.py +50 -0
- agentsquire/skills.py +95 -0
- agentsquire/sources.py +120 -0
- agentsquire/staleness.py +76 -0
- agentsquire/stamping.py +74 -0
- agentsquire/verbs.py +335 -0
- agentsquire-0.2.2.dist-info/METADATA +190 -0
- agentsquire-0.2.2.dist-info/RECORD +15 -0
- agentsquire-0.2.2.dist-info/WHEEL +4 -0
- agentsquire-0.2.2.dist-info/licenses/LICENSE +21 -0
agentsquire/__init__.py
ADDED
|
@@ -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
|
agentsquire/harnesses.py
ADDED
|
@@ -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
|
+
)
|