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/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())
|
agentsquire/staleness.py
ADDED
|
@@ -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
|
agentsquire/stamping.py
ADDED
|
@@ -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
|