@delorenj/pjangler 1.2.19 → 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 +2353 -518
- package/dist/mcp-server.js +2331 -494
- package/package.json +14 -3
- package/templates/commonproject/copier.yml +6 -4
- package/templates/commonproject/template/.agents/hooks/README.md +13 -26
- package/templates/commonproject/template/.agents/hooks/lib/local-config.sh +4 -14
- package/templates/commonproject/template/.agents/hooks/sync.py +5 -6
- package/templates/commonproject/template/.agents/local.example.json +2 -8
- package/templates/commonproject/template/.agents/skills.json +6 -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 +16 -16
- 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 +12 -1
- package/templates/hermes-agent/template/.scripts/momo-wip-lock.py +137 -0
- package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +21 -0
- package/templates/hermes-agent/template/SOUL.md.jinja +36 -15
- 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/commonproject/template/.mise/scripts/link-project-skills-to-clis.sh +0 -110
- package/templates/commonproject/template/.mise/scripts/unlink-project-skills-from-clis.sh +0 -45
- 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,23 +20,26 @@ 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 = "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"
|
|
26
|
+
[[hooks.enter]]
|
|
23
27
|
script = "[ -f '{% raw %}{{config_root}}{% endraw %}/.mise/scripts/codegraph.sh' ] && '{% raw %}{{config_root}}{% endraw %}/.mise/scripts/codegraph.sh' || true"
|
|
24
28
|
{%- if agent_hooks_layer %}
|
|
25
29
|
[[hooks.enter]]
|
|
26
|
-
script = "'{% raw %}{{config_root}}{% endraw %}/.mise/scripts/link-project-skills-to-clis.sh'"
|
|
27
|
-
[[hooks.enter]]
|
|
28
30
|
script = "'{% raw %}{{config_root}}{% endraw %}/.agents/hooks/sync.py' --install --quiet"
|
|
29
31
|
{%- endif %}
|
|
30
32
|
{%- if agent_hooks_layer %}
|
|
31
33
|
[[hooks.leave]]
|
|
32
|
-
script = "'{% raw %}{{config_root}}{% endraw %}/.mise/scripts/unlink-project-skills-from-clis.sh'"
|
|
33
|
-
[[hooks.leave]]
|
|
34
34
|
script = "'{% raw %}{{config_root}}{% endraw %}/.agents/hooks/sync.py' --uninstall --quiet"
|
|
35
35
|
{%- endif %}
|
|
36
36
|
|
|
37
37
|
[[watch_files]]
|
|
38
38
|
patterns = ["AGENTS.md"]
|
|
39
39
|
task = "link-agentfiles"
|
|
40
|
+
[[watch_files]]
|
|
41
|
+
patterns = [".agents/skills.json"]
|
|
42
|
+
task = "skills-sync"
|
|
40
43
|
{% if agent_hooks_layer %}
|
|
41
44
|
# Re-fan-out the agent hooks whenever the single source of truth changes.
|
|
42
45
|
[[watch_files]]
|
|
@@ -46,6 +49,15 @@ task = "hooks-sync"
|
|
|
46
49
|
[tasks.link-agentfiles]
|
|
47
50
|
description = "Symlink all agent files to AGENTS.md"
|
|
48
51
|
run = "'{% raw %}{{config_root}}{% endraw %}/.mise/scripts/link-agentfiles.sh'"
|
|
52
|
+
|
|
53
|
+
[tasks.skills-sync]
|
|
54
|
+
description = "Sync skills from manifest to local CLI dirs"
|
|
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'"
|
|
49
61
|
{% if agent_hooks_layer %}
|
|
50
62
|
# --- Project-scoped agent hooks + skill fan-out (see .agents/hooks/README.md) ---
|
|
51
63
|
|
|
@@ -60,18 +72,6 @@ run = "'{% raw %}{{config_root}}{% endraw %}/.agents/hooks/sync.py' --check"
|
|
|
60
72
|
[tasks.hooks-uninstall]
|
|
61
73
|
description = "Remove per-user agent-hook injections (codex/kimi/hermes)"
|
|
62
74
|
run = "'{% raw %}{{config_root}}{% endraw %}/.agents/hooks/sync.py' --uninstall"
|
|
63
|
-
|
|
64
|
-
[tasks.link-project-skills-to-clis]
|
|
65
|
-
description = "Fan .agents/skills out to each agent CLI; honors local.json defer_to_global + disabled"
|
|
66
|
-
run = "'{% raw %}{{config_root}}{% endraw %}/.mise/scripts/link-project-skills-to-clis.sh'"
|
|
67
|
-
|
|
68
|
-
[tasks.unlink-project-skills-from-clis]
|
|
69
|
-
description = "Remove project skill symlinks from shared per-CLI dirs"
|
|
70
|
-
run = "'{% raw %}{{config_root}}{% endraw %}/.mise/scripts/unlink-project-skills-from-clis.sh'"
|
|
71
|
-
|
|
72
|
-
[tasks.skills-relink]
|
|
73
|
-
description = "Re-fan the project skill set to all CLIs"
|
|
74
|
-
run = "'{% raw %}{{config_root}}{% endraw %}/.mise/scripts/link-project-skills-to-clis.sh'"
|
|
75
75
|
{% endif %}
|
|
76
76
|
[tasks.hindsight-setup]
|
|
77
77
|
description = "Provision this dev's shared project Hindsight key from 1Password into .env (op inject)"
|