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/sources.py ADDED
@@ -0,0 +1,120 @@
1
+ """Skill acquisition sources.
2
+
3
+ The install/status/update/uninstall pipeline consumes the ``SkillSource``
4
+ seam — list skills, materialize one — so new acquisition mechanisms (e.g. a
5
+ whitelisted remote repository) can be added without changing verb signatures.
6
+ Bundled consumer package data is the launch implementation.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import tempfile
12
+ from contextlib import contextmanager
13
+ from dataclasses import dataclass
14
+ from importlib import resources
15
+ from pathlib import Path
16
+ from typing import Iterator, Protocol, runtime_checkable
17
+
18
+ from agentsquire.hashing import skill_content_hash
19
+
20
+ # Pyproject entry-point group a consumer may register under to mark itself
21
+ # skill-carrying, reserved for a future environment-wide listing. Registering
22
+ # is optional and nothing reads it at launch: no verb changes behaviour.
23
+ ENTRY_POINT_GROUP = "agentsquire.skills"
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class SourceSkill:
28
+ """One skill a source can provide: its name and content hash."""
29
+
30
+ name: str
31
+ content_hash: str
32
+
33
+
34
+ @runtime_checkable
35
+ class SkillSource(Protocol):
36
+ """Where skills come from: list them, materialize one to a directory."""
37
+
38
+ def list_skills(self) -> list[SourceSkill]: ...
39
+
40
+ def materialize(self, name: str):
41
+ """Context manager yielding a filesystem Path to the named skill dir."""
42
+ ...
43
+
44
+
45
+ class DirectorySource:
46
+ """Skills laid out as subdirectories of a plain local directory."""
47
+
48
+ def __init__(self, root: Path):
49
+ self.root = Path(root)
50
+
51
+ def list_skills(self) -> list[SourceSkill]:
52
+ return [
53
+ SourceSkill(name=entry.name, content_hash=skill_content_hash(entry))
54
+ for entry in sorted(self.root.iterdir())
55
+ if entry.is_dir()
56
+ ]
57
+
58
+ @contextmanager
59
+ def materialize(self, name: str) -> Iterator[Path]:
60
+ skill_dir = self.root / name
61
+ if not skill_dir.is_dir():
62
+ raise KeyError(name)
63
+ yield skill_dir
64
+
65
+
66
+ class BundledPackageDataSource:
67
+ """Skills shipped as package data inside an installed consumer package.
68
+
69
+ Backed by importlib.resources, so it works from an installed wheel with no
70
+ source checkout; zip-served packages are extracted to a temp dir on
71
+ materialize.
72
+ """
73
+
74
+ def __init__(self, package: str, resource_path: str = "skills"):
75
+ self.package = package
76
+ self.resource_path = resource_path
77
+
78
+ def _root(self):
79
+ traversable = resources.files(self.package)
80
+ for part in self.resource_path.split("/"):
81
+ traversable = traversable.joinpath(part)
82
+ return traversable
83
+
84
+ def list_skills(self) -> list[SourceSkill]:
85
+ skills = []
86
+ for entry in self._root().iterdir():
87
+ if entry.is_dir():
88
+ with self._materialized(entry) as path:
89
+ skills.append(
90
+ SourceSkill(name=entry.name, content_hash=skill_content_hash(path))
91
+ )
92
+ return sorted(skills, key=lambda skill: skill.name)
93
+
94
+ @contextmanager
95
+ def materialize(self, name: str) -> Iterator[Path]:
96
+ for entry in self._root().iterdir():
97
+ if entry.name == name and entry.is_dir():
98
+ with self._materialized(entry) as path:
99
+ yield path
100
+ return
101
+ raise KeyError(name)
102
+
103
+ @contextmanager
104
+ def _materialized(self, traversable) -> Iterator[Path]:
105
+ if isinstance(traversable, Path):
106
+ yield traversable
107
+ return
108
+ with tempfile.TemporaryDirectory() as tmp:
109
+ target = Path(tmp) / traversable.name
110
+ _copy_traversable(traversable, target)
111
+ yield target
112
+
113
+
114
+ def _copy_traversable(traversable, target: Path) -> None:
115
+ target.mkdir(parents=True, exist_ok=True)
116
+ for child in traversable.iterdir():
117
+ if child.is_dir():
118
+ _copy_traversable(child, target / child.name)
119
+ else:
120
+ (target / child.name).write_bytes(child.read_bytes())
@@ -0,0 +1,76 @@
1
+ """Notice-only staleness check for consumer CLI startup.
2
+
3
+ One call, placed at the top of the consumer's entry point. With
4
+ update-available skills it prints a single advisory line on stderr naming
5
+ the real CLI and the exact update command. It never prompts, never touches
6
+ any input stream, never writes to stdout, never mutates installed skills,
7
+ never raises, and never changes the consumer command's exit code. The
8
+ explicit skills update verb is the sole updater. Decisions are local hash
9
+ compares only.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ from agentsquire.harnesses import HarnessBackend, default_registry
19
+ from agentsquire.roots import resolve_roots
20
+ from agentsquire.sources import SkillSource
21
+ from agentsquire.verbs import SkillState, status
22
+
23
+
24
+ def check_stale(
25
+ source: SkillSource,
26
+ backend: HarnessBackend | None = None,
27
+ *,
28
+ scope: str = "user",
29
+ home: Path | None = None,
30
+ project: Path | None = None,
31
+ prog_name: str,
32
+ update_command: str,
33
+ ) -> None:
34
+ """Print a one-line stderr notice when installed skills have updates.
35
+
36
+ With no backend given, every detected harness is checked. Returns None
37
+ always and swallows its own errors: a startup hook must never break, or
38
+ change the exit code of, the consumer command it runs inside.
39
+ """
40
+ try:
41
+ # Notice gate: any non-empty value of CI or AGENTSQUIRE_NO_UPDATE_CHECK
42
+ # disables (presence-disables, the NO_COLOR convention; empty string
43
+ # counts as unset). The notice is deliberately NOT gated on an
44
+ # interactive TTY: the primary reader is an agent harness that runs the
45
+ # consumer with captured (non-TTY) stderr and must still see that an
46
+ # update is available. CI stays the escape hatch for pipelines.
47
+ if os.environ.get("CI") or os.environ.get("AGENTSQUIRE_NO_UPDATE_CHECK"):
48
+ return
49
+ home, project = resolve_roots(home, project)
50
+ backends = (
51
+ [backend]
52
+ if backend is not None
53
+ else default_registry().detect(home=home, project=project)
54
+ )
55
+ stale_names = sorted(
56
+ {
57
+ skill.name
58
+ for candidate in backends
59
+ for skill in status(
60
+ source, candidate, scope=scope, home=home, project=project
61
+ )
62
+ if skill.state is SkillState.UPDATE_AVAILABLE
63
+ }
64
+ )
65
+ if not stale_names:
66
+ return
67
+ count = len(stale_names)
68
+ noun = "skill" if count == 1 else "skills"
69
+ names = ", ".join(stale_names)
70
+ print(
71
+ f"{prog_name}: a skills update is available for"
72
+ f" {count} {noun} ({names}); run `{update_command}`",
73
+ file=sys.stderr,
74
+ )
75
+ except Exception:
76
+ return
@@ -0,0 +1,74 @@
1
+ """Provenance stamp read/write in SKILL.md frontmatter (``metadata.agentsquire``).
2
+
3
+ The stamp is spliced into the frontmatter textually so every non-stamp byte —
4
+ frontmatter and body — is unchanged from the source, then the result is
5
+ re-parsed and checked against the expected mapping; a splice that would alter
6
+ or lose anything raises instead of installing a corrupted SKILL.md.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ import textwrap
13
+
14
+ import yaml
15
+
16
+ from agentsquire.hashing import STAMP_KEY
17
+
18
+
19
+ class StampError(Exception):
20
+ """Stamping could not proceed without altering non-stamp content."""
21
+
22
+
23
+ def read_stamp(text: str) -> dict | None:
24
+ """The stamp mapping from SKILL.md text, or None if absent/unparseable."""
25
+ if not text.startswith("---"):
26
+ return None
27
+ parts = text.split("---", 2)
28
+ if len(parts) < 3:
29
+ return None
30
+ try:
31
+ frontmatter = yaml.safe_load(parts[1])
32
+ except yaml.YAMLError:
33
+ return None
34
+ if not isinstance(frontmatter, dict):
35
+ return None
36
+ metadata = frontmatter.get("metadata")
37
+ if not isinstance(metadata, dict):
38
+ return None
39
+ stamp = metadata.get(STAMP_KEY)
40
+ return stamp if isinstance(stamp, dict) else None
41
+
42
+
43
+ def stamped_skill_md(text: str, stamp: dict) -> str:
44
+ """SKILL.md text with the stamp added under the frontmatter metadata map."""
45
+ parts = text.split("---", 2)
46
+ if not text.startswith("---") or len(parts) < 3:
47
+ raise StampError("SKILL.md has no frontmatter block to stamp")
48
+ frontmatter_text, body = parts[1], parts[2]
49
+ frontmatter = yaml.safe_load(frontmatter_text)
50
+ if not isinstance(frontmatter, dict):
51
+ raise StampError("SKILL.md frontmatter is not a mapping")
52
+
53
+ stamp_block = textwrap.indent(
54
+ yaml.safe_dump({STAMP_KEY: dict(stamp)}, sort_keys=True), " "
55
+ )
56
+ if not frontmatter_text.endswith("\n"):
57
+ frontmatter_text += "\n"
58
+ if "metadata" not in frontmatter:
59
+ new_frontmatter = frontmatter_text + "metadata:\n" + stamp_block
60
+ else:
61
+ opener = re.search(r"^metadata:[ \t]*$", frontmatter_text, flags=re.MULTILINE)
62
+ if opener is None:
63
+ raise StampError("existing metadata map is not in block style; cannot stamp")
64
+ insert_at = opener.end() + 1 # just past the metadata: line's newline
65
+ new_frontmatter = (
66
+ frontmatter_text[:insert_at] + stamp_block + frontmatter_text[insert_at:]
67
+ )
68
+
69
+ stamped = f"---{new_frontmatter}---{body}"
70
+ expected = dict(frontmatter)
71
+ expected["metadata"] = {**frontmatter.get("metadata", {}), STAMP_KEY: dict(stamp)}
72
+ if yaml.safe_load(stamped.split("---", 2)[1]) != expected:
73
+ raise StampError("stamping would alter existing frontmatter; refusing")
74
+ return stamped
agentsquire/verbs.py ADDED
@@ -0,0 +1,335 @@
1
+ """Skill lifecycle verbs, generic over sources and harness backends.
2
+
3
+ Verbs take a SkillSource and a HarnessBackend and never know about any
4
+ particular consumer or harness (REQ-05, REQ-15). Install is copy + provenance
5
+ stamp (D-05): the whole skill directory is copied — symlinks dereferenced, so
6
+ the installed tree is regular files with no references into site-packages —
7
+ and the SKILL.md frontmatter gains a ``metadata.agentsquire`` stamp recording
8
+ installer, source package, and content hash.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import enum
14
+ import shutil
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+
18
+ import agentsquire
19
+ from agentsquire.harnesses import HarnessBackend
20
+ from agentsquire.hashing import skill_content_hash
21
+ from agentsquire.skills import SkillViolation, validate_skill_dir
22
+ from agentsquire.sources import SkillSource, SourceSkill
23
+ from agentsquire.stamping import StampError, read_stamp, stamped_skill_md
24
+
25
+
26
+ class SkillState(enum.Enum):
27
+ """The four mutually exclusive states of a skill per harness x scope."""
28
+
29
+ NOT_INSTALLED = "not-installed"
30
+ UP_TO_DATE = "up-to-date"
31
+ UPDATE_AVAILABLE = "update-available"
32
+ LOCALLY_MODIFIED = "locally-modified"
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class SkillStatus:
37
+ """One skill's classified state at its target path."""
38
+
39
+ name: str
40
+ state: SkillState
41
+ path: Path
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class InstalledSkill:
46
+ """One skill copied and stamped at its installed path."""
47
+
48
+ name: str
49
+ path: Path
50
+ content_hash: str
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class SkippedSkill:
55
+ """One skill a verb left untouched, and why."""
56
+
57
+ name: str
58
+ reason: str
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class InstallResult:
63
+ """Per-skill outcomes of one install run; ok is False when any rejected."""
64
+
65
+ installed: list[InstalledSkill] = field(default_factory=list)
66
+ up_to_date: list[SkillStatus] = field(default_factory=list)
67
+ rejected: list[SkillViolation] = field(default_factory=list)
68
+ skipped: list[SkippedSkill] = field(default_factory=list)
69
+
70
+ @property
71
+ def ok(self) -> bool:
72
+ return not self.rejected
73
+
74
+
75
+ def _classify(entry: SourceSkill, target: Path) -> SkillState:
76
+ """One skill's state from local hash compares only (REQ-11, no network).
77
+
78
+ A same-named directory without our stamp, or whose content no longer
79
+ matches its own stamped hash, is locally modified — never ours to touch.
80
+ A symlink (dangling or live) is likewise present-but-not-ours.
81
+ """
82
+ if target.is_symlink():
83
+ # exists() follows the link and would misjudge the target — a dangling
84
+ # link reads as absent, a live one as its destination. A symlink is
85
+ # never ours: report it locally-modified so install/update skip it and
86
+ # never rmtree the link (BUG-02).
87
+ return SkillState.LOCALLY_MODIFIED
88
+ if not target.exists():
89
+ return SkillState.NOT_INSTALLED
90
+ manifest = target / "SKILL.md"
91
+ if not manifest.is_file():
92
+ return SkillState.LOCALLY_MODIFIED
93
+ stamp = read_stamp(manifest.read_text())
94
+ stamped_hash = stamp.get("content_hash") if stamp else None
95
+ if stamped_hash != skill_content_hash(target):
96
+ return SkillState.LOCALLY_MODIFIED
97
+ if stamped_hash != entry.content_hash:
98
+ return SkillState.UPDATE_AVAILABLE
99
+ return SkillState.UP_TO_DATE
100
+
101
+
102
+ def _skip_reason(state: SkillState, target: Path) -> str:
103
+ """The reason a skill was skipped, made specific when the target is a
104
+ symlink so the remedy — remove the link, or force — is clear (BUG-02)."""
105
+ if target.is_symlink():
106
+ return (
107
+ f"{state.value} (target is a symlink; remove it, "
108
+ "or update --force to replace it)"
109
+ )
110
+ return state.value
111
+
112
+
113
+ def status(
114
+ source: SkillSource,
115
+ backend: HarnessBackend,
116
+ *,
117
+ scope: str,
118
+ home: Path,
119
+ project: Path,
120
+ ) -> list[SkillStatus]:
121
+ """Classify every source skill against the backend's scope directory."""
122
+ target_root = backend.skills_dir(scope, home=home, project=project)
123
+ return [
124
+ SkillStatus(
125
+ name=entry.name,
126
+ state=_classify(entry, target_root / entry.name),
127
+ path=target_root / entry.name,
128
+ )
129
+ for entry in source.list_skills()
130
+ ]
131
+
132
+
133
+ def install(
134
+ source: SkillSource,
135
+ backend: HarnessBackend,
136
+ *,
137
+ scope: str,
138
+ home: Path,
139
+ project: Path,
140
+ source_package: str,
141
+ source_version: str,
142
+ ) -> InstallResult:
143
+ """Copy every valid skill in the source into the backend's scope directory.
144
+
145
+ Idempotent: a current install is a byte-identical no-op reported as
146
+ up-to-date (REQ-10). Invalid skills are rejected with their violations
147
+ without stopping the run; stale or locally-modified installs are skipped,
148
+ never overwritten — updating is the update verb's job.
149
+ """
150
+ target_root = backend.skills_dir(scope, home=home, project=project)
151
+ result = InstallResult()
152
+ for entry in source.list_skills():
153
+ with source.materialize(entry.name) as skill_dir:
154
+ violations = validate_skill_dir(skill_dir)
155
+ if violations:
156
+ result.rejected.extend(violations)
157
+ continue
158
+ target = target_root / entry.name
159
+ state = _classify(entry, target)
160
+ if state is SkillState.UP_TO_DATE:
161
+ result.up_to_date.append(
162
+ SkillStatus(name=entry.name, state=state, path=target)
163
+ )
164
+ continue
165
+ if state is not SkillState.NOT_INSTALLED:
166
+ result.skipped.append(
167
+ SkippedSkill(name=entry.name, reason=_skip_reason(state, target))
168
+ )
169
+ continue
170
+ try:
171
+ result.installed.append(
172
+ _copy_and_stamp(skill_dir, target, source_package, source_version)
173
+ )
174
+ except StampError as error:
175
+ result.rejected.append(_stamp_violation(entry.name, error))
176
+ return result
177
+
178
+
179
+ def _copy_and_stamp(
180
+ skill_dir: Path, target: Path, source_package: str, source_version: str
181
+ ) -> InstalledSkill:
182
+ content_hash = skill_content_hash(skill_dir)
183
+ stamp = {
184
+ "installer": "agentsquire",
185
+ "installer_version": agentsquire.__version__,
186
+ "source_package": source_package,
187
+ "source_version": source_version,
188
+ "content_hash": content_hash,
189
+ }
190
+ # Stamp before touching the target: an unstampable manifest raises here,
191
+ # leaving neither a partial copy nor (on update) a removed old install.
192
+ stamped = stamped_skill_md((skill_dir / "SKILL.md").read_text(), stamp)
193
+ # Remove any prior entry symlink-safely: unlink a link (dangling or live)
194
+ # rather than rmtree through it, and rmtree only a real directory (BUG-02).
195
+ if target.is_symlink():
196
+ target.unlink()
197
+ elif target.is_dir():
198
+ shutil.rmtree(target)
199
+ elif target.exists():
200
+ target.unlink()
201
+ target.parent.mkdir(parents=True, exist_ok=True)
202
+ shutil.copytree(skill_dir, target, symlinks=False)
203
+ (target / "SKILL.md").write_text(stamped)
204
+ return InstalledSkill(name=target.name, path=target, content_hash=content_hash)
205
+
206
+
207
+ def _stamp_violation(name: str, error: StampError) -> SkillViolation:
208
+ return SkillViolation(skill=name, rule="unstampable", message=f"{name}: {error}")
209
+
210
+
211
+ @dataclass(frozen=True)
212
+ class UpdateResult:
213
+ """Per-skill outcomes of one update run; ok is False when any rejected."""
214
+
215
+ updated: list[InstalledSkill] = field(default_factory=list)
216
+ up_to_date: list[SkillStatus] = field(default_factory=list)
217
+ rejected: list[SkillViolation] = field(default_factory=list)
218
+ skipped: list[SkippedSkill] = field(default_factory=list)
219
+
220
+ @property
221
+ def ok(self) -> bool:
222
+ return not self.rejected
223
+
224
+
225
+ def update(
226
+ source: SkillSource,
227
+ backend: HarnessBackend,
228
+ *,
229
+ scope: str,
230
+ home: Path,
231
+ project: Path,
232
+ source_package: str,
233
+ source_version: str,
234
+ force: bool = False,
235
+ ) -> UpdateResult:
236
+ """Re-copy and re-stamp every update-available skill (REQ-12).
237
+
238
+ Locally-modified installs are skipped — the result names each one — and
239
+ only an explicit force overwrites them. Not-installed skills are left to
240
+ the install verb.
241
+ """
242
+ target_root = backend.skills_dir(scope, home=home, project=project)
243
+ result = UpdateResult()
244
+ for entry in source.list_skills():
245
+ target = target_root / entry.name
246
+ state = _classify(entry, target)
247
+ if state is SkillState.UP_TO_DATE:
248
+ result.up_to_date.append(
249
+ SkillStatus(name=entry.name, state=state, path=target)
250
+ )
251
+ continue
252
+ if state is SkillState.NOT_INSTALLED or (
253
+ state is SkillState.LOCALLY_MODIFIED and not force
254
+ ):
255
+ result.skipped.append(
256
+ SkippedSkill(name=entry.name, reason=_skip_reason(state, target))
257
+ )
258
+ continue
259
+ with source.materialize(entry.name) as skill_dir:
260
+ violations = validate_skill_dir(skill_dir)
261
+ if violations:
262
+ result.rejected.extend(violations)
263
+ continue
264
+ try:
265
+ result.updated.append(
266
+ _copy_and_stamp(skill_dir, target, source_package, source_version)
267
+ )
268
+ except StampError as error:
269
+ result.rejected.append(_stamp_violation(entry.name, error))
270
+ return result
271
+
272
+
273
+ @dataclass(frozen=True)
274
+ class RemovedSkill:
275
+ """One our-stamped skill directory that uninstall removed."""
276
+
277
+ name: str
278
+ path: Path
279
+
280
+
281
+ @dataclass(frozen=True)
282
+ class UninstallResult:
283
+ """Per-skill outcomes of one uninstall run."""
284
+
285
+ removed: list[RemovedSkill] = field(default_factory=list)
286
+ skipped: list[SkippedSkill] = field(default_factory=list)
287
+
288
+
289
+ def uninstall(
290
+ source: SkillSource,
291
+ backend: HarnessBackend,
292
+ *,
293
+ scope: str,
294
+ home: Path,
295
+ project: Path,
296
+ source_package: str,
297
+ ) -> UninstallResult:
298
+ """Remove installed skill dirs whose stamp names us and the consumer.
299
+
300
+ A same-named directory that is unstamped, or stamped by a different
301
+ installer or source package, is left in place with the reason recorded
302
+ (REQ-13) — it is not ours to delete.
303
+ """
304
+ target_root = backend.skills_dir(scope, home=home, project=project)
305
+ result = UninstallResult()
306
+ for entry in source.list_skills():
307
+ target = target_root / entry.name
308
+
309
+ def skip(reason: str) -> None:
310
+ result.skipped.append(SkippedSkill(name=entry.name, reason=reason))
311
+
312
+ if target.is_symlink():
313
+ # Present but not ours, and never rmtree a link: leave it (BUG-02).
314
+ skip("target is a symlink; not removing")
315
+ continue
316
+ if not target.exists():
317
+ skip("not-installed")
318
+ continue
319
+ manifest = target / "SKILL.md"
320
+ stamp = read_stamp(manifest.read_text()) if manifest.is_file() else None
321
+ if stamp is None:
322
+ skip("no agentsquire provenance stamp; not removing")
323
+ continue
324
+ if stamp.get("installer") != "agentsquire":
325
+ skip(f"stamped by installer {stamp.get('installer')!r}; not removing")
326
+ continue
327
+ if stamp.get("source_package") != source_package:
328
+ skip(
329
+ f"installed from package {stamp.get('source_package')!r},"
330
+ f" not {source_package!r}; not removing"
331
+ )
332
+ continue
333
+ shutil.rmtree(target)
334
+ result.removed.append(RemovedSkill(name=entry.name, path=target))
335
+ return result