@delorenj/pjangler 1.2.21 → 1.2.25
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.
- package/dist/index.js +1852 -368
- package/dist/mcp-server.js +1782 -296
- package/package.json +14 -3
- package/templates/commonproject/copier.yml +3 -0
- package/templates/commonproject/template/.mise/scripts/provision-bmad-skills.py +480 -0
- package/templates/commonproject/template/.mise/scripts/sync-skills.py +449 -0
- package/templates/commonproject/template/mise.toml.jinja +9 -2
- package/templates/commonproject/tests/test_provision_bmad_skills.py +203 -0
- package/templates/commonproject/tests/test_sync_skills_topology.py +120 -0
- package/templates/hermes-agent/README.md +70 -29
- package/templates/hermes-agent/copier.yml +9 -6
- package/templates/hermes-agent/docs/architecture.md +75 -51
- package/templates/hermes-agent/docs/fleet-control-plane/architecture.md +11 -6
- package/templates/hermes-agent/docs/fleet-control-plane/epics-and-stories.md +2 -2
- package/templates/hermes-agent/docs/fleet-control-plane/prd.md +2 -3
- package/templates/hermes-agent/docs/operations.md +179 -81
- package/templates/hermes-agent/docs/runbooks/runtime-checkpoint-repair.md +31 -171
- package/templates/hermes-agent/docs/sentinel/README.md +10 -10
- package/templates/hermes-agent/docs/sentinel/architecture.md +6 -8
- package/templates/hermes-agent/docs/sentinel/development.md +12 -15
- package/templates/hermes-agent/docs/sentinel.md +1 -1
- package/templates/hermes-agent/install-local.sh +41 -4
- package/templates/hermes-agent/runtime-scaffold/README.md +4 -2
- package/templates/hermes-agent/scripts/degit-runtime.sh +192 -0
- package/templates/hermes-agent/scripts/fleet-prune-debris.sh +128 -0
- package/templates/hermes-agent/scripts/fleet-sync.sh +164 -7
- package/templates/hermes-agent/scripts/hermes-runtime-templatize.py +321 -0
- package/templates/hermes-agent/scripts/migrate-profile-scoped-chat-secrets.py +136 -0
- package/templates/hermes-agent/scripts/momo-unify-agent.py +224 -0
- package/templates/hermes-agent/scripts/repair-runtime-checkpoint.sh +7 -119
- package/templates/hermes-agent/template/.gitignore.jinja +5 -2
- package/templates/hermes-agent/template/.runtime-scaffold/README.md +4 -2
- package/templates/hermes-agent/template/.scripts/00-banner.sh +1 -1
- package/templates/hermes-agent/template/.scripts/01-config.sh +3 -0
- package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +12 -1
- package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +55 -129
- package/templates/hermes-agent/template/.scripts/30-telegram.sh +276 -22
- package/templates/hermes-agent/template/.scripts/31-slack.sh +362 -0
- package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +8 -66
- package/templates/hermes-agent/template/.scripts/70-systemd.sh +44 -30
- package/templates/hermes-agent/template/.scripts/80-registry.sh +93 -13
- package/templates/hermes-agent/template/.scripts/99-summary.sh +1 -4
- package/templates/hermes-agent/template/.scripts/_lib.sh +66 -1
- package/templates/hermes-agent/template/.scripts/checkpoint.sh +3 -2
- package/templates/hermes-agent/template/.scripts/config.example.toml +10 -10
- package/templates/hermes-agent/template/.scripts/heartbeat.sh +1 -1
- package/templates/hermes-agent/template/SOUL.md.jinja +2 -2
- package/templates/hermes-agent/template/hermes.jinja +6 -3
- package/templates/hermes-agent/template/role.yaml.jinja +24 -13
- package/templates/hermes-agent/tests/test_bloodbank_consumer_contract.py +429 -130
- package/templates/hermes-agent/tests/test_fleet_upgrade_contract.py +176 -0
- package/templates/hermes-agent/tests/test_launcher_chat_secret_isolation.py +85 -0
- package/templates/hermes-agent/tests/test_local_runtime_provisioning.py +130 -0
- package/templates/hermes-agent/tests/test_operator_docs_local_runtime_contract.py +132 -0
- package/templates/hermes-agent/tests/test_profile_env_detach_contract.py +14 -0
- package/templates/hermes-agent/tests/test_profile_scoped_chat_secrets.py +89 -0
- package/templates/hermes-agent/tests/test_repository_privacy_contract.py +32 -0
- package/templates/hermes-agent/tests/test_slack_provisioning.py +344 -0
- package/templates/hermes-agent/tests/test_telegram_provisioning.py +475 -0
- package/templates/hermes-agent/.codegraph/daemon.pid +0 -6
- package/templates/hermes-agent/.omo/run-continuation/ses_0e5b28303ffeXxL53hZjKggXDW.json +0 -10
- package/templates/hermes-agent/runtime-scaffold/bloodbank-consumer.py +0 -181
- package/templates/hermes-agent/template/.runtime-scaffold/bloodbank-consumer.py +0 -181
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
sync-skills.py — manifest-driven skill fanout.
|
|
4
|
+
Replaces the old symlink-based skillex monolithic fanout.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from urllib.parse import unquote, urlparse
|
|
16
|
+
|
|
17
|
+
# The list of agent CLI skill directories (relative to home or project root)
|
|
18
|
+
CLI_SKILL_DIRS = [
|
|
19
|
+
".gemini/skills",
|
|
20
|
+
".codex/skills",
|
|
21
|
+
".kimi/skills",
|
|
22
|
+
".augment/skills",
|
|
23
|
+
".config/opencode/skills",
|
|
24
|
+
".hermes/skills",
|
|
25
|
+
".claude/skills",
|
|
26
|
+
".openclaw/skills",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_args():
|
|
31
|
+
parser = argparse.ArgumentParser(
|
|
32
|
+
description="Sync skills from manifest to agent CLIs."
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--scope",
|
|
36
|
+
choices=["global", "project"],
|
|
37
|
+
required=True,
|
|
38
|
+
help="Whether to sync global skills or project-local skills.",
|
|
39
|
+
)
|
|
40
|
+
return parser.parse_args()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_manifest(manifest_path):
|
|
44
|
+
if not manifest_path.exists():
|
|
45
|
+
return {"skills": []}
|
|
46
|
+
with open(manifest_path, "r") as handle:
|
|
47
|
+
return json.load(handle)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def validate_skill_name(name):
|
|
51
|
+
if not isinstance(name, str) or not name:
|
|
52
|
+
raise ValueError("Skill name must be a non-empty string")
|
|
53
|
+
if name in {".", ".."} or Path(name).is_absolute():
|
|
54
|
+
raise ValueError(f"Unsafe skill name: {name!r}")
|
|
55
|
+
if "/" in name or "\\" in name or Path(name).name != name:
|
|
56
|
+
raise ValueError(f"Skill name must be one path component: {name!r}")
|
|
57
|
+
return name
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def manifest_skill_name(skill):
|
|
61
|
+
if isinstance(skill, str):
|
|
62
|
+
path = skill if "/" in skill else f"all-skills/{skill}"
|
|
63
|
+
return validate_skill_name(path.split("/")[-1])
|
|
64
|
+
if not isinstance(skill, dict):
|
|
65
|
+
raise ValueError(f"Skill entry must be a string or object: {skill!r}")
|
|
66
|
+
return validate_skill_name(skill.get("name"))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def validate_manifest_skill_names(manifest):
|
|
70
|
+
skills = manifest.get("skills", [])
|
|
71
|
+
if not isinstance(skills, list):
|
|
72
|
+
raise ValueError("Manifest skills must be an array")
|
|
73
|
+
for skill in skills:
|
|
74
|
+
manifest_skill_name(skill)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def assert_real_directory_chain(root, target):
|
|
78
|
+
root = root.resolve(strict=True)
|
|
79
|
+
target = target.absolute()
|
|
80
|
+
try:
|
|
81
|
+
relative = target.relative_to(root)
|
|
82
|
+
except ValueError as error:
|
|
83
|
+
raise ValueError(f"Destination escapes root {root}: {target}") from error
|
|
84
|
+
current = root
|
|
85
|
+
for part in relative.parts:
|
|
86
|
+
current = current / part
|
|
87
|
+
if not current.exists() and not current.is_symlink():
|
|
88
|
+
break
|
|
89
|
+
if current.is_symlink():
|
|
90
|
+
raise ValueError(f"Refusing symlinked destination directory: {current}")
|
|
91
|
+
if not current.is_dir():
|
|
92
|
+
raise ValueError(f"Destination parent is not a directory: {current}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def preflight_cli_dirs(cli_dirs_base, skill_names):
|
|
96
|
+
base = cli_dirs_base.resolve(strict=True)
|
|
97
|
+
active = []
|
|
98
|
+
for cli_rel_path in CLI_SKILL_DIRS:
|
|
99
|
+
cli_dir = base / cli_rel_path
|
|
100
|
+
parent = cli_dir.parent
|
|
101
|
+
if not parent.exists() and not parent.is_symlink():
|
|
102
|
+
continue
|
|
103
|
+
assert_real_directory_chain(base, parent)
|
|
104
|
+
if parent.is_symlink() or not parent.is_dir():
|
|
105
|
+
raise ValueError(f"Unsafe CLI destination parent: {parent}")
|
|
106
|
+
if cli_dir.is_symlink():
|
|
107
|
+
# Generated projects intentionally expose the single managed skill
|
|
108
|
+
# projection to Claude as `.claude/skills -> ../.agents/skills`.
|
|
109
|
+
# Accept only that exact lexical alias. Resolving an arbitrary
|
|
110
|
+
# directory symlink here would let cleanup below traverse outside
|
|
111
|
+
# the project (or through a broken target) before failing.
|
|
112
|
+
raw_target = os.readlink(cli_dir)
|
|
113
|
+
canonical_alias = (
|
|
114
|
+
cli_rel_path == ".claude/skills"
|
|
115
|
+
and raw_target == "../.agents/skills"
|
|
116
|
+
)
|
|
117
|
+
managed_skills = base / ".agents" / "skills"
|
|
118
|
+
if not canonical_alias:
|
|
119
|
+
raise ValueError(f"Refusing symlinked CLI skills directory: {cli_dir}")
|
|
120
|
+
assert_real_directory_chain(base, managed_skills)
|
|
121
|
+
if (
|
|
122
|
+
not managed_skills.exists()
|
|
123
|
+
or managed_skills.is_symlink()
|
|
124
|
+
or not managed_skills.is_dir()
|
|
125
|
+
):
|
|
126
|
+
raise ValueError(
|
|
127
|
+
f"Canonical Claude skills alias target is not a real directory: {managed_skills}"
|
|
128
|
+
)
|
|
129
|
+
resolved_cli = cli_dir.resolve(strict=True)
|
|
130
|
+
if resolved_cli != managed_skills.resolve(strict=True):
|
|
131
|
+
raise ValueError(
|
|
132
|
+
f"Canonical Claude skills alias escapes managed project skills: {cli_dir}"
|
|
133
|
+
)
|
|
134
|
+
# The alias already exposes the managed projection. Do not fan out
|
|
135
|
+
# into `.agents/skills` a second time: that directory is owned by
|
|
136
|
+
# the pinned provisioner and may also contain real custom skills.
|
|
137
|
+
continue
|
|
138
|
+
else:
|
|
139
|
+
if cli_dir.exists() and not cli_dir.is_dir():
|
|
140
|
+
raise ValueError(f"CLI skills destination is not a directory: {cli_dir}")
|
|
141
|
+
real_parent = parent.resolve(strict=True)
|
|
142
|
+
expected_cli = real_parent / cli_dir.name
|
|
143
|
+
if cli_dir.exists() and cli_dir.resolve(strict=True) != expected_cli:
|
|
144
|
+
raise ValueError(f"CLI skills directory escapes its parent: {cli_dir}")
|
|
145
|
+
for name in skill_names:
|
|
146
|
+
destination = expected_cli / name
|
|
147
|
+
if destination.parent != expected_cli or len(destination.relative_to(expected_cli).parts) != 1:
|
|
148
|
+
raise ValueError(f"Skill destination escapes CLI directory: {destination}")
|
|
149
|
+
active.append((cli_dir, expected_cli))
|
|
150
|
+
return active
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def revalidate_cli_dir(cli_dirs_base, cli_dir, expected_cli):
|
|
154
|
+
"""Revalidate the complete destination chain at the mutation boundary."""
|
|
155
|
+
base = cli_dirs_base.resolve(strict=True)
|
|
156
|
+
assert_real_directory_chain(base, cli_dir.parent)
|
|
157
|
+
if cli_dir.parent.is_symlink() or not cli_dir.parent.is_dir():
|
|
158
|
+
raise ValueError(f"Unsafe CLI destination parent after preflight: {cli_dir.parent}")
|
|
159
|
+
current_expected = cli_dir.parent.resolve(strict=True) / cli_dir.name
|
|
160
|
+
if current_expected != expected_cli:
|
|
161
|
+
raise ValueError(f"CLI destination parent changed after preflight: {cli_dir}")
|
|
162
|
+
if cli_dir.is_symlink():
|
|
163
|
+
raise ValueError(f"CLI skills directory changed to a symlink after preflight: {cli_dir}")
|
|
164
|
+
if cli_dir.exists():
|
|
165
|
+
if not cli_dir.is_dir() or cli_dir.resolve(strict=True) != expected_cli:
|
|
166
|
+
raise ValueError(f"Unsafe CLI skills directory after preflight: {cli_dir}")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def ensure_cache_dir():
|
|
170
|
+
cache_dir = Path(os.path.expanduser("~/.agents/.cache/skills"))
|
|
171
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
172
|
+
return cache_dir
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def sync_registry(registry_url):
|
|
176
|
+
# Sanitize registry_url to create a folder name
|
|
177
|
+
safe_name = re.sub(r"[^a-zA-Z0-9]", "_", registry_url)
|
|
178
|
+
cache_dir = (
|
|
179
|
+
Path(os.path.expanduser("~/.agents/.cache/registries")) / safe_name
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
if cache_dir.exists():
|
|
183
|
+
try:
|
|
184
|
+
print(f"Updating registry {registry_url}...")
|
|
185
|
+
subprocess.run(
|
|
186
|
+
["git", "-C", str(cache_dir), "pull"],
|
|
187
|
+
check=True,
|
|
188
|
+
capture_output=True,
|
|
189
|
+
)
|
|
190
|
+
except subprocess.CalledProcessError as error:
|
|
191
|
+
print(
|
|
192
|
+
f"Warning: Failed to update registry {registry_url}: "
|
|
193
|
+
f"{error.stderr.decode()}",
|
|
194
|
+
file=sys.stderr,
|
|
195
|
+
)
|
|
196
|
+
else:
|
|
197
|
+
cache_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
198
|
+
print(f"Cloning registry {registry_url}...")
|
|
199
|
+
subprocess.run(["git", "clone", registry_url, str(cache_dir)], check=True)
|
|
200
|
+
|
|
201
|
+
return cache_dir
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def sync_git_skill(name, source, version, cache_dir):
|
|
205
|
+
target_dir = cache_dir / name
|
|
206
|
+
if target_dir.exists():
|
|
207
|
+
# Just pull if it exists
|
|
208
|
+
try:
|
|
209
|
+
print(f"Updating git skill {name} in cache...")
|
|
210
|
+
subprocess.run(
|
|
211
|
+
["git", "-C", str(target_dir), "pull"],
|
|
212
|
+
check=True,
|
|
213
|
+
capture_output=True,
|
|
214
|
+
)
|
|
215
|
+
except subprocess.CalledProcessError as error:
|
|
216
|
+
print(
|
|
217
|
+
f"Warning: Failed to update {name}: {error.stderr.decode()}",
|
|
218
|
+
file=sys.stderr,
|
|
219
|
+
)
|
|
220
|
+
else:
|
|
221
|
+
print(f"Cloning git skill {name} to cache...")
|
|
222
|
+
subprocess.run(["git", "clone", source, str(target_dir)], check=True)
|
|
223
|
+
|
|
224
|
+
if version:
|
|
225
|
+
subprocess.run(
|
|
226
|
+
["git", "-C", str(target_dir), "checkout", version], check=True
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
return target_dir
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def resolve_skill_path(
|
|
233
|
+
skill, cache_dir, base_dir, default_registry, registry_cache
|
|
234
|
+
):
|
|
235
|
+
if isinstance(skill, str):
|
|
236
|
+
path = skill if "/" in skill else f"all-skills/{skill}"
|
|
237
|
+
name = validate_skill_name(path.split("/")[-1])
|
|
238
|
+
skill = {"name": name, "registry_path": path}
|
|
239
|
+
|
|
240
|
+
name = validate_skill_name(skill.get("name"))
|
|
241
|
+
|
|
242
|
+
if "registry_path" in skill:
|
|
243
|
+
registry_url = skill.get("registry", default_registry)
|
|
244
|
+
if registry_url not in registry_cache:
|
|
245
|
+
registry_cache[registry_url] = sync_registry(registry_url)
|
|
246
|
+
full_path = registry_cache[registry_url] / skill["registry_path"]
|
|
247
|
+
if not full_path.exists():
|
|
248
|
+
print(
|
|
249
|
+
f"Warning: Registry skill {name} not found at {full_path}",
|
|
250
|
+
file=sys.stderr,
|
|
251
|
+
)
|
|
252
|
+
return name, None
|
|
253
|
+
return name, full_path
|
|
254
|
+
|
|
255
|
+
source = skill.get("source", "")
|
|
256
|
+
if source.startswith("git@") or source.startswith("https://"):
|
|
257
|
+
return name, sync_git_skill(
|
|
258
|
+
name, source, skill.get("version"), cache_dir
|
|
259
|
+
)
|
|
260
|
+
parsed = urlparse(source)
|
|
261
|
+
if parsed.scheme == "file":
|
|
262
|
+
if parsed.netloc not in {"", "localhost"}:
|
|
263
|
+
raise ValueError(f"Non-local file URI authority for skill {name}: {parsed.netloc}")
|
|
264
|
+
if parsed.query or parsed.fragment:
|
|
265
|
+
raise ValueError(f"file:// source must encode query/fragment characters: {source}")
|
|
266
|
+
local_path = Path(unquote(parsed.path))
|
|
267
|
+
full_path = (base_dir / local_path).resolve()
|
|
268
|
+
if not full_path.exists():
|
|
269
|
+
print(
|
|
270
|
+
f"Warning: Local skill {name} not found at {full_path}",
|
|
271
|
+
file=sys.stderr,
|
|
272
|
+
)
|
|
273
|
+
return name, None
|
|
274
|
+
return name, full_path
|
|
275
|
+
|
|
276
|
+
print(
|
|
277
|
+
f"Warning: Unknown source type for skill {name}: {source}",
|
|
278
|
+
file=sys.stderr,
|
|
279
|
+
)
|
|
280
|
+
return name, None
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def fanout_to_cli(
|
|
284
|
+
cli_dirs_base, skills_map, active_cli_dirs=None, before_mutation=None
|
|
285
|
+
):
|
|
286
|
+
"""
|
|
287
|
+
Creates symlinks in each of the CLI_SKILL_DIRS relative to cli_dirs_base
|
|
288
|
+
pointing to the resolved paths in skills_map.
|
|
289
|
+
"""
|
|
290
|
+
skill_names = [validate_skill_name(name) for name in skills_map]
|
|
291
|
+
if active_cli_dirs is None:
|
|
292
|
+
active_cli_dirs = preflight_cli_dirs(cli_dirs_base, skill_names)
|
|
293
|
+
if before_mutation is not None:
|
|
294
|
+
before_mutation()
|
|
295
|
+
linked_total = 0
|
|
296
|
+
for cli_dir, expected_cli in active_cli_dirs:
|
|
297
|
+
revalidate_cli_dir(cli_dirs_base, cli_dir, expected_cli)
|
|
298
|
+
if not cli_dir.is_symlink():
|
|
299
|
+
cli_dir.mkdir(parents=True, exist_ok=True)
|
|
300
|
+
revalidate_cli_dir(cli_dirs_base, cli_dir, expected_cli)
|
|
301
|
+
real_cli_dir = expected_cli.resolve(strict=True)
|
|
302
|
+
|
|
303
|
+
for name, actual_path in skills_map.items():
|
|
304
|
+
revalidate_cli_dir(cli_dirs_base, cli_dir, expected_cli)
|
|
305
|
+
symlink_target = real_cli_dir / name
|
|
306
|
+
if symlink_target.parent != real_cli_dir:
|
|
307
|
+
raise ValueError(f"Skill destination escapes CLI directory: {symlink_target}")
|
|
308
|
+
|
|
309
|
+
# If it's a symlink already pointing to the right place, skip
|
|
310
|
+
if (
|
|
311
|
+
symlink_target.is_symlink()
|
|
312
|
+
and os.readlink(symlink_target) == str(actual_path)
|
|
313
|
+
):
|
|
314
|
+
continue
|
|
315
|
+
|
|
316
|
+
# If it exists but is wrong, remove it
|
|
317
|
+
if symlink_target.exists() or symlink_target.is_symlink():
|
|
318
|
+
revalidate_cli_dir(cli_dirs_base, cli_dir, expected_cli)
|
|
319
|
+
if symlink_target.is_dir() and not symlink_target.is_symlink():
|
|
320
|
+
shutil.rmtree(symlink_target)
|
|
321
|
+
else:
|
|
322
|
+
symlink_target.unlink()
|
|
323
|
+
|
|
324
|
+
revalidate_cli_dir(cli_dirs_base, cli_dir, expected_cli)
|
|
325
|
+
os.symlink(actual_path, symlink_target)
|
|
326
|
+
linked_total += 1
|
|
327
|
+
print(f"→ {symlink_target} -> {actual_path}")
|
|
328
|
+
|
|
329
|
+
print(
|
|
330
|
+
f"sync-skills: {linked_total} new/updated symlink(s) "
|
|
331
|
+
f"across CLIs in {cli_dirs_base}"
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def main():
|
|
336
|
+
args = parse_args()
|
|
337
|
+
|
|
338
|
+
global_manifest_path = Path(os.path.expanduser("~/.agents/skills.json"))
|
|
339
|
+
project_manifest_path = Path(os.getcwd()) / ".agents" / "skills.json"
|
|
340
|
+
|
|
341
|
+
# Destination topology is a security boundary. Validate every active CLI
|
|
342
|
+
# directory before cloning/updating registries, creating caches, or changing
|
|
343
|
+
# any skill link so one unsafe/broken symlink produces zero mutation.
|
|
344
|
+
preflight_names = []
|
|
345
|
+
preflight_base = Path(os.path.expanduser("~"))
|
|
346
|
+
if args.scope == "global":
|
|
347
|
+
preflight_manifest = load_manifest(global_manifest_path)
|
|
348
|
+
validate_manifest_skill_names(preflight_manifest)
|
|
349
|
+
preflight_names.extend(
|
|
350
|
+
manifest_skill_name(skill)
|
|
351
|
+
for skill in preflight_manifest.get("skills", [])
|
|
352
|
+
)
|
|
353
|
+
else:
|
|
354
|
+
preflight_base = Path(os.getcwd())
|
|
355
|
+
preflight_manifest = load_manifest(project_manifest_path)
|
|
356
|
+
validate_manifest_skill_names(preflight_manifest)
|
|
357
|
+
preflight_names.extend(
|
|
358
|
+
manifest_skill_name(skill)
|
|
359
|
+
for skill in preflight_manifest.get("skills", [])
|
|
360
|
+
)
|
|
361
|
+
if preflight_manifest.get("inherit_global", False):
|
|
362
|
+
inherited = load_manifest(global_manifest_path)
|
|
363
|
+
validate_manifest_skill_names(inherited)
|
|
364
|
+
preflight_names.extend(
|
|
365
|
+
manifest_skill_name(skill)
|
|
366
|
+
for skill in inherited.get("skills", [])
|
|
367
|
+
)
|
|
368
|
+
active_cli_dirs = preflight_cli_dirs(preflight_base, preflight_names)
|
|
369
|
+
cache_dir = ensure_cache_dir()
|
|
370
|
+
|
|
371
|
+
skills_to_sync = {} # name -> actual_path
|
|
372
|
+
registry_cache = {}
|
|
373
|
+
|
|
374
|
+
if args.scope == "global":
|
|
375
|
+
print(f"Loading global manifest from {global_manifest_path}")
|
|
376
|
+
manifest = load_manifest(global_manifest_path)
|
|
377
|
+
validate_manifest_skill_names(manifest)
|
|
378
|
+
default_registry = manifest.get(
|
|
379
|
+
"registry", "https://github.com/delorenj/skillex.git"
|
|
380
|
+
)
|
|
381
|
+
base_dir = global_manifest_path.parent
|
|
382
|
+
for skill in manifest.get("skills", []):
|
|
383
|
+
name, path = resolve_skill_path(
|
|
384
|
+
skill,
|
|
385
|
+
cache_dir,
|
|
386
|
+
base_dir,
|
|
387
|
+
default_registry,
|
|
388
|
+
registry_cache,
|
|
389
|
+
)
|
|
390
|
+
if path:
|
|
391
|
+
skills_to_sync[name] = path
|
|
392
|
+
|
|
393
|
+
# Fanout globally (home dir)
|
|
394
|
+
fanout_to_cli(
|
|
395
|
+
Path(os.path.expanduser("~")),
|
|
396
|
+
skills_to_sync,
|
|
397
|
+
active_cli_dirs=active_cli_dirs,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
elif args.scope == "project":
|
|
401
|
+
print(f"Loading project manifest from {project_manifest_path}")
|
|
402
|
+
manifest = load_manifest(project_manifest_path)
|
|
403
|
+
validate_manifest_skill_names(manifest)
|
|
404
|
+
default_registry = manifest.get(
|
|
405
|
+
"registry", "https://github.com/delorenj/skillex.git"
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
# Check if we should inherit global skills
|
|
409
|
+
if manifest.get("inherit_global", False):
|
|
410
|
+
print("Inheriting global skills...")
|
|
411
|
+
global_manifest = load_manifest(global_manifest_path)
|
|
412
|
+
validate_manifest_skill_names(global_manifest)
|
|
413
|
+
global_registry = global_manifest.get(
|
|
414
|
+
"registry", "https://github.com/delorenj/skillex.git"
|
|
415
|
+
)
|
|
416
|
+
for skill in global_manifest.get("skills", []):
|
|
417
|
+
name, path = resolve_skill_path(
|
|
418
|
+
skill,
|
|
419
|
+
cache_dir,
|
|
420
|
+
global_manifest_path.parent,
|
|
421
|
+
global_registry,
|
|
422
|
+
registry_cache,
|
|
423
|
+
)
|
|
424
|
+
if path:
|
|
425
|
+
skills_to_sync[name] = path
|
|
426
|
+
|
|
427
|
+
base_dir = project_manifest_path.parent
|
|
428
|
+
for skill in manifest.get("skills", []):
|
|
429
|
+
name, path = resolve_skill_path(
|
|
430
|
+
skill,
|
|
431
|
+
cache_dir,
|
|
432
|
+
base_dir,
|
|
433
|
+
default_registry,
|
|
434
|
+
registry_cache,
|
|
435
|
+
)
|
|
436
|
+
if path:
|
|
437
|
+
# Overrides global skill of the same name
|
|
438
|
+
skills_to_sync[name] = path
|
|
439
|
+
|
|
440
|
+
# Fanout locally (project dir)
|
|
441
|
+
fanout_to_cli(
|
|
442
|
+
Path(os.getcwd()),
|
|
443
|
+
skills_to_sync,
|
|
444
|
+
active_cli_dirs=active_cli_dirs,
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
if __name__ == "__main__":
|
|
449
|
+
main()
|
|
@@ -20,7 +20,9 @@ script = "'{% raw %}{{config_root}}{% endraw %}/.mise/scripts/link-agentfiles.sh
|
|
|
20
20
|
[[hooks.enter]]
|
|
21
21
|
script = "op inject -i .env.op > .env"
|
|
22
22
|
[[hooks.enter]]
|
|
23
|
-
script = "
|
|
23
|
+
script = "python3 '{% raw %}{{config_root}}{% endraw %}/.mise/scripts/provision-bmad-skills.py'"
|
|
24
|
+
[[hooks.enter]]
|
|
25
|
+
script = "python3 '{% raw %}{{config_root}}{% endraw %}/.mise/scripts/sync-skills.py' --scope project"
|
|
24
26
|
[[hooks.enter]]
|
|
25
27
|
script = "[ -f '{% raw %}{{config_root}}{% endraw %}/.mise/scripts/codegraph.sh' ] && '{% raw %}{{config_root}}{% endraw %}/.mise/scripts/codegraph.sh' || true"
|
|
26
28
|
{%- if agent_hooks_layer %}
|
|
@@ -50,7 +52,12 @@ run = "'{% raw %}{{config_root}}{% endraw %}/.mise/scripts/link-agentfiles.sh'"
|
|
|
50
52
|
|
|
51
53
|
[tasks.skills-sync]
|
|
52
54
|
description = "Sync skills from manifest to local CLI dirs"
|
|
53
|
-
|
|
55
|
+
depends = ["skills-provision-bmad"]
|
|
56
|
+
run = "python3 '{% raw %}{{config_root}}{% endraw %}/.mise/scripts/sync-skills.py' --scope project"
|
|
57
|
+
|
|
58
|
+
[tasks.skills-provision-bmad]
|
|
59
|
+
description = "Provision pinned BMAD skills from the Skillex pack"
|
|
60
|
+
run = "python3 '{% raw %}{{config_root}}{% endraw %}/.mise/scripts/provision-bmad-skills.py'"
|
|
54
61
|
{% if agent_hooks_layer %}
|
|
55
62
|
# --- Project-scoped agent hooks + skill fan-out (see .agents/hooks/README.md) ---
|
|
56
63
|
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import stat
|
|
8
|
+
import tempfile
|
|
9
|
+
import unittest
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
ROOT = Path(__file__).parents[1]
|
|
15
|
+
SCRIPT = ROOT / "template" / ".mise" / "scripts" / "provision-bmad-skills.py"
|
|
16
|
+
SPEC = importlib.util.spec_from_file_location("provision_bmad_skills", SCRIPT)
|
|
17
|
+
assert SPEC is not None and SPEC.loader is not None
|
|
18
|
+
PROVISIONER = importlib.util.module_from_spec(SPEC)
|
|
19
|
+
SPEC.loader.exec_module(PROVISIONER)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@contextmanager
|
|
23
|
+
def working_directory(path: Path):
|
|
24
|
+
previous = Path.cwd()
|
|
25
|
+
os.chdir(path)
|
|
26
|
+
try:
|
|
27
|
+
yield
|
|
28
|
+
finally:
|
|
29
|
+
os.chdir(previous)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def snapshot(path: Path):
|
|
33
|
+
try:
|
|
34
|
+
mode = path.lstat().st_mode
|
|
35
|
+
except FileNotFoundError:
|
|
36
|
+
return ("missing",)
|
|
37
|
+
permissions = stat.S_IMODE(mode)
|
|
38
|
+
if stat.S_ISLNK(mode):
|
|
39
|
+
return ("symlink", permissions, os.readlink(path))
|
|
40
|
+
if stat.S_ISREG(mode):
|
|
41
|
+
return ("file", permissions, path.read_bytes())
|
|
42
|
+
if stat.S_ISDIR(mode):
|
|
43
|
+
return (
|
|
44
|
+
"directory",
|
|
45
|
+
permissions,
|
|
46
|
+
tuple((child.name, snapshot(child)) for child in sorted(path.iterdir())),
|
|
47
|
+
)
|
|
48
|
+
return ("special", permissions)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ProvisionTransactionTests(unittest.TestCase):
|
|
52
|
+
def setUp(self) -> None:
|
|
53
|
+
source = os.environ.get("PJ_BMAD_PACK_ROOT")
|
|
54
|
+
if not source:
|
|
55
|
+
self.skipTest("PJ_BMAD_PACK_ROOT must identify the canonical candidate pack")
|
|
56
|
+
self.temporary = tempfile.TemporaryDirectory(prefix="commonproject-bmad-transaction-")
|
|
57
|
+
self.root = Path(self.temporary.name)
|
|
58
|
+
self.pack = self.root / "pack"
|
|
59
|
+
shutil.copytree(source, self.pack, symlinks=True)
|
|
60
|
+
self.project = self.root / "project"
|
|
61
|
+
skills = self.project / ".agents" / "skills"
|
|
62
|
+
skills.mkdir(parents=True)
|
|
63
|
+
manifest = self.project / ".agents" / "skills.json"
|
|
64
|
+
private = skills / "bmad-private-custom"
|
|
65
|
+
private.mkdir()
|
|
66
|
+
(private / "SKILL.md").write_text("private custom must survive\n")
|
|
67
|
+
manifest.write_text(
|
|
68
|
+
json.dumps(
|
|
69
|
+
{
|
|
70
|
+
"skills": [
|
|
71
|
+
{"name": "custom", "source": "file:///custom"},
|
|
72
|
+
{"name": "bmad-private-custom", "source": private.as_uri()},
|
|
73
|
+
{"name": "bmad-stale", "source": (self.pack / "bmad-stale").as_uri()},
|
|
74
|
+
]
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
+ "\n"
|
|
78
|
+
)
|
|
79
|
+
manifest.chmod(0o600)
|
|
80
|
+
copied = skills / "bmad-agent-pm"
|
|
81
|
+
copied.mkdir()
|
|
82
|
+
(copied / "legacy.txt").write_text("preserve exactly\n")
|
|
83
|
+
(skills / "bmad-agent-analyst").symlink_to(
|
|
84
|
+
self.pack / "bmad-agent-analyst", target_is_directory=True
|
|
85
|
+
)
|
|
86
|
+
(skills / "bmad-stale").symlink_to(
|
|
87
|
+
self.pack / "bmad-stale", target_is_directory=True
|
|
88
|
+
)
|
|
89
|
+
custom = skills / "custom"
|
|
90
|
+
custom.mkdir()
|
|
91
|
+
(custom / "SKILL.md").write_text("custom\n")
|
|
92
|
+
self.previous_pack = os.environ.get("PJ_BMAD_PACK_ROOT")
|
|
93
|
+
os.environ["PJ_BMAD_PACK_ROOT"] = str(self.pack)
|
|
94
|
+
|
|
95
|
+
def tearDown(self) -> None:
|
|
96
|
+
if self.previous_pack is None:
|
|
97
|
+
os.environ.pop("PJ_BMAD_PACK_ROOT", None)
|
|
98
|
+
else:
|
|
99
|
+
os.environ["PJ_BMAD_PACK_ROOT"] = self.previous_pack
|
|
100
|
+
self.temporary.cleanup()
|
|
101
|
+
|
|
102
|
+
def test_nth_link_failure_restores_exact_project_state(self) -> None:
|
|
103
|
+
before = snapshot(self.project)
|
|
104
|
+
|
|
105
|
+
def fail_fifth(target: Path, link: Path, index: int) -> None:
|
|
106
|
+
if index == 5:
|
|
107
|
+
raise OSError("injected fifth-link failure")
|
|
108
|
+
link.symlink_to(target, target_is_directory=True)
|
|
109
|
+
|
|
110
|
+
with working_directory(self.project):
|
|
111
|
+
with self.assertRaisesRegex(OSError, "fifth-link"):
|
|
112
|
+
PROVISIONER.provision(create_link=fail_fifth)
|
|
113
|
+
|
|
114
|
+
self.assertEqual(snapshot(self.project), before)
|
|
115
|
+
|
|
116
|
+
def test_pack_mutation_after_preflight_rolls_back_exactly(self) -> None:
|
|
117
|
+
before = snapshot(self.project)
|
|
118
|
+
|
|
119
|
+
def mutate_pack() -> None:
|
|
120
|
+
(self.pack / "bmad-agent-pm" / "SKILL.md").write_text("mutated after preflight\n")
|
|
121
|
+
|
|
122
|
+
with working_directory(self.project):
|
|
123
|
+
with self.assertRaisesRegex(ValueError, "digest mismatch"):
|
|
124
|
+
PROVISIONER.provision(after_preflight=mutate_pack)
|
|
125
|
+
|
|
126
|
+
self.assertEqual(snapshot(self.project), before)
|
|
127
|
+
|
|
128
|
+
def test_malformed_manifest_fails_before_creating_skills_or_temp_paths(self) -> None:
|
|
129
|
+
project = self.root / "malformed-project"
|
|
130
|
+
agents = project / ".agents"
|
|
131
|
+
agents.mkdir(parents=True)
|
|
132
|
+
manifest = agents / "skills.json"
|
|
133
|
+
manifest.write_bytes(b"{malformed\n")
|
|
134
|
+
manifest.chmod(0o600)
|
|
135
|
+
before = snapshot(project)
|
|
136
|
+
|
|
137
|
+
with working_directory(project):
|
|
138
|
+
with self.assertRaises(json.JSONDecodeError):
|
|
139
|
+
PROVISIONER.provision()
|
|
140
|
+
|
|
141
|
+
self.assertEqual(snapshot(project), before)
|
|
142
|
+
self.assertFalse((agents / "skills").exists())
|
|
143
|
+
self.assertFalse(any(path.name.startswith(".bmad-transaction-") for path in agents.iterdir()))
|
|
144
|
+
|
|
145
|
+
def test_applied_projection_mismatch_rolls_back_exactly(self) -> None:
|
|
146
|
+
before = snapshot(self.project)
|
|
147
|
+
|
|
148
|
+
def corrupt_projection(_manifest: Path, skills: Path) -> None:
|
|
149
|
+
link = skills / "bmad-agent-analyst"
|
|
150
|
+
link.unlink()
|
|
151
|
+
link.symlink_to("/tmp/wrong-after-apply", target_is_directory=True)
|
|
152
|
+
|
|
153
|
+
with working_directory(self.project):
|
|
154
|
+
with self.assertRaisesRegex(ValueError, "link differs from plan"):
|
|
155
|
+
PROVISIONER.provision(after_apply=corrupt_projection)
|
|
156
|
+
|
|
157
|
+
self.assertEqual(snapshot(self.project), before)
|
|
158
|
+
|
|
159
|
+
def test_success_is_complete_and_idempotent(self) -> None:
|
|
160
|
+
with working_directory(self.project):
|
|
161
|
+
self.assertGreater(PROVISIONER.provision(), 0)
|
|
162
|
+
after_first = snapshot(self.project)
|
|
163
|
+
self.assertEqual(PROVISIONER.provision(), 0)
|
|
164
|
+
|
|
165
|
+
self.assertEqual(snapshot(self.project), after_first)
|
|
166
|
+
skills = self.project / ".agents" / "skills"
|
|
167
|
+
self.assertTrue((skills / "bmad-private-custom").is_dir())
|
|
168
|
+
self.assertEqual(
|
|
169
|
+
(skills / "bmad-private-custom" / "SKILL.md").read_text(),
|
|
170
|
+
"private custom must survive\n",
|
|
171
|
+
)
|
|
172
|
+
self.assertFalse((skills / "bmad-stale").exists())
|
|
173
|
+
manifest = json.loads((self.project / ".agents" / "skills.json").read_text())
|
|
174
|
+
self.assertIn(
|
|
175
|
+
{
|
|
176
|
+
"name": "bmad-private-custom",
|
|
177
|
+
"source": (skills / "bmad-private-custom").as_uri(),
|
|
178
|
+
},
|
|
179
|
+
manifest["skills"],
|
|
180
|
+
)
|
|
181
|
+
self.assertNotIn("bmad-stale", [entry.get("name") for entry in manifest["skills"]])
|
|
182
|
+
self.assertTrue(
|
|
183
|
+
all(
|
|
184
|
+
(skills / path.name).is_symlink()
|
|
185
|
+
for path in PROVISIONER.validate_trusted_pack(self.pack)
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def test_unowned_bmad_prefixed_custom_skill_is_preserved(self) -> None:
|
|
190
|
+
custom = self.project / ".agents" / "skills" / "bmad-private-custom"
|
|
191
|
+
before = snapshot(custom)
|
|
192
|
+
|
|
193
|
+
with working_directory(self.project):
|
|
194
|
+
self.assertGreater(PROVISIONER.provision(), 0)
|
|
195
|
+
after_first = snapshot(self.project)
|
|
196
|
+
self.assertEqual(PROVISIONER.provision(), 0)
|
|
197
|
+
|
|
198
|
+
self.assertEqual(snapshot(custom), before)
|
|
199
|
+
self.assertEqual(snapshot(self.project), after_first)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
if __name__ == "__main__":
|
|
203
|
+
unittest.main()
|