@delorenj/pjangler 1.4.6 → 1.5.2

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.
Files changed (83) hide show
  1. package/README.md +41 -596
  2. package/dist/assets/project-notebook-skill/SHA256SUMS +5 -5
  3. package/dist/assets/project-notebook-skill/export-manifest.json +5 -5
  4. package/dist/assets/project-notebook-skill/hooks/session-end.sh +20 -1
  5. package/dist/assets/project-notebook-skill/hooks/session-start.sh +20 -1
  6. package/dist/assets/project-notebook-skill/references/configuration.md +2 -0
  7. package/dist/assets/project-notebook-skill/scripts/project-hooks.py +33 -3
  8. package/dist/assets/project-notebook-skill/tests/test_project_hooks.py +37 -0
  9. package/dist/index.js +9385 -18379
  10. package/dist/mcp-server.js +9432 -17852
  11. package/dist/notebook/journal-service-main.js +5682 -0
  12. package/dist/project-registry-service.js +748 -0
  13. package/dist/prompt.js +2 -1
  14. package/package.json +15 -10
  15. package/scripts/install-project-registry.mjs +34 -0
  16. package/scripts/migrate-project-registry.mjs +117 -0
  17. package/src/project/registryClient.ts +62 -0
  18. package/templates/commonproject/copier.yml +16 -19
  19. package/templates/commonproject/template/.agents/hooks/README.md +10 -4
  20. package/templates/commonproject/template/.agents/hooks/hermes/hindsight-hook.sh +12 -2
  21. package/templates/commonproject/template/.agents/hooks/hooks.master.json +10 -10
  22. package/templates/commonproject/template/.agents/hooks/lib/hook-guard.sh +13 -0
  23. package/templates/commonproject/template/.agents/hooks/sync.py +66 -17
  24. package/templates/commonproject/template/.agents/skills.json +0 -1
  25. package/templates/commonproject/template/.github/workflows/code-review.yml.jinja +7 -1
  26. package/templates/commonproject/template/.project.json.jinja +1 -1
  27. package/templates/commonproject/template/mise.toml.jinja +11 -21
  28. package/contracts/fleet-contract.yaml +0 -584
  29. package/templates/commonproject/template/.mise/scripts/provision-packs.py +0 -587
  30. package/templates/commonproject/template/.mise/scripts/sync-skills.py +0 -2183
  31. package/templates/hermes-agent/copier.yml +0 -186
  32. package/templates/hermes-agent/template/.gitignore.jinja +0 -11
  33. package/templates/hermes-agent/template/.runtime-scaffold/.gitattributes +0 -10
  34. package/templates/hermes-agent/template/.runtime-scaffold/.gitignore.jinja +0 -44
  35. package/templates/hermes-agent/template/.runtime-scaffold/README.md +0 -56
  36. package/templates/hermes-agent/template/.runtime-scaffold/decisions/.gitkeep +0 -2
  37. package/templates/hermes-agent/template/.runtime-scaffold/logs/.gitkeep +0 -1
  38. package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +0 -50
  39. package/templates/hermes-agent/template/.runtime-scaffold/memories/USER.md +0 -51
  40. package/templates/hermes-agent/template/.scripts/00-banner.sh +0 -21
  41. package/templates/hermes-agent/template/.scripts/01-config.sh +0 -60
  42. package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +0 -42
  43. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +0 -222
  44. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +0 -199
  45. package/templates/hermes-agent/template/.scripts/30-telegram.sh +0 -363
  46. package/templates/hermes-agent/template/.scripts/31-slack.sh +0 -457
  47. package/templates/hermes-agent/template/.scripts/40-plane.sh +0 -60
  48. package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +0 -373
  49. package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +0 -18
  50. package/templates/hermes-agent/template/.scripts/70-systemd.sh +0 -354
  51. package/templates/hermes-agent/template/.scripts/80-registry.sh +0 -185
  52. package/templates/hermes-agent/template/.scripts/99-summary.sh +0 -87
  53. package/templates/hermes-agent/template/.scripts/_lib.sh +0 -1138
  54. package/templates/hermes-agent/template/.scripts/channel-transaction.py +0 -2340
  55. package/templates/hermes-agent/template/.scripts/checkpoint.sh +0 -82
  56. package/templates/hermes-agent/template/.scripts/config.example.toml +0 -70
  57. package/templates/hermes-agent/template/.scripts/credential-launch.sh +0 -85
  58. package/templates/hermes-agent/template/.scripts/heartbeat.sh +0 -283
  59. package/templates/hermes-agent/template/.scripts/lib/fleet-env.sh +0 -202
  60. package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +0 -761
  61. package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +0 -182
  62. package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +0 -108
  63. package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +0 -199
  64. package/templates/hermes-agent/template/.scripts/lib/voice-config.py +0 -546
  65. package/templates/hermes-agent/template/.scripts/lifecycle.sh +0 -126
  66. package/templates/hermes-agent/template/.scripts/momo-wip-lock.py +0 -137
  67. package/templates/hermes-agent/template/.scripts/providers/linear.sh +0 -329
  68. package/templates/hermes-agent/template/.scripts/providers/plane.sh +0 -671
  69. package/templates/hermes-agent/template/.scripts/providers/trello.sh +0 -241
  70. package/templates/hermes-agent/template/.scripts/reporter-watchdog.py +0 -242
  71. package/templates/hermes-agent/template/.scripts/secret-scan.py +0 -134
  72. package/templates/hermes-agent/template/.scripts/sentinel/bin/emit-event.py +0 -106
  73. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +0 -339
  74. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +0 -179
  75. package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +0 -162
  76. package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +0 -42
  77. package/templates/hermes-agent/template/.scripts/sentinel/docs/continuous-ticket-orchestration.md +0 -77
  78. package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +0 -116
  79. package/templates/hermes-agent/template/.scripts/store-onepassword-secret.py +0 -260
  80. package/templates/hermes-agent/template/SOUL.md.jinja +0 -197
  81. package/templates/hermes-agent/template/hermes.jinja +0 -109
  82. package/templates/hermes-agent/template/momo.jinja +0 -177
  83. package/templates/hermes-agent/template/role.yaml.jinja +0 -112
@@ -1,2183 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- sync-skills.py — manifest-driven skill fanout.
4
-
5
- Projects the `skills[]` entries and the `packs[]` members declared by the global
6
- and project `.agents/skills.json` manifests into every supported agent CLI
7
- skills directory. Replaces the old symlink-based skillex monolithic fanout.
8
- """
9
-
10
- import argparse
11
- import hashlib
12
- import json
13
- import os
14
- import pathlib
15
- import re
16
- import shutil
17
- import stat
18
- import subprocess
19
- import sys
20
- import tomllib
21
- from pathlib import Path
22
- from urllib.parse import unquote, urlparse
23
-
24
- DEFAULT_REGISTRY = "https://github.com/delorenj/skillex.git"
25
-
26
- # The supported agent CLI skill directories, per scope (relative to home for
27
- # `--scope global`, to the project root for `--scope project`). Exactly six
28
- # CLIs are supported; opencode uses a different path per scope.
29
- CLI_SKILL_DIRS = {
30
- "global": [
31
- ".claude/skills",
32
- ".codex/skills",
33
- ".gemini/skills",
34
- ".copilot/skills",
35
- ".config/opencode/skills",
36
- ".kimi-code/skills",
37
- ],
38
- "project": [
39
- ".claude/skills",
40
- ".codex/skills",
41
- ".gemini/skills",
42
- ".copilot/skills",
43
- ".opencode/skills",
44
- ".kimi-code/skills",
45
- ],
46
- }
47
-
48
- # Directories this engine used to write to. They are never written to any more
49
- # and never auto-deleted; `--prune-retired` opts in to removing ONLY managed
50
- # symlinks left behind inside them.
51
- RETIRED_CLI_SKILL_DIRS = [
52
- ".augment/skills",
53
- ".hermes/skills",
54
- ".openclaw/skills",
55
- ".kimi/skills",
56
- ".crush/skills",
57
- ".cursor/skills",
58
- ]
59
-
60
- # `~/.hermes/skills` is a writable Hermes runtime OVERLAY, not a projection of
61
- # this manifest. It is never written to, never reported, and never pruned.
62
- NEVER_PRUNE_DIRS = {".hermes/skills"}
63
-
64
- # The single managed projection every CLI skills directory may alias.
65
- MANAGED_SKILLS_RELATIVE = (".agents", "skills")
66
-
67
- # The canonical identifier shape the contract mandates for pack and skill names
68
- # (section 1): lowercase alphanumerics and dashes, no leading/trailing dash.
69
- CANONICAL_NAME_PATTERN = re.compile(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$")
70
-
71
- # Canonical pack identifier shape. Enforced as a WARNING so packs that predate
72
- # the convention stay resolvable; the hard requirement (one safe path
73
- # component) is enforced by validate_skill_name().
74
- PACK_NAME_PATTERN = CANONICAL_NAME_PATTERN
75
-
76
-
77
- class PackUnavailable(Exception):
78
- """The pack (or a declared member of it) is simply not installed here.
79
-
80
- Only this failure class is downgraded to a warning by `"optional": true`.
81
- Integrity failures — symlinks in the payload, path escapes, identity
82
- mismatches, checksum mismatches — always raise and are never suppressed.
83
- """
84
-
85
-
86
- # --------------------------------------------------------------------------- #
87
- # Which repo does this script own?
88
- #
89
- # A mise ENTER hook runs with cwd set to the directory the user cd'd into, not
90
- # to config_root -- and that is true for a PARENT config's hook too, so entering
91
- # 33GOD/pjangler fired 33GOD's copy of this script with cwd=pjangler. It then
92
- # loaded pjangler's manifest and rewrote pjangler's .agents/ and CLI skill dirs.
93
- # `mise run <task>` DOES run at config_root, which is why the cwd assumption
94
- # looked correct for years: only the enter-hook path was ever wrong.
95
- #
96
- # So the subject is taken explicitly, defaulting to $MISE_CONFIG_ROOT (mise
97
- # exports it per hook, correctly), and it must match the root this script file
98
- # actually lives in. A repo-local script never acts on cwd, and never on a
99
- # sibling.
100
- # --------------------------------------------------------------------------- #
101
-
102
-
103
- def parse_args():
104
- parser = argparse.ArgumentParser(
105
- description="Sync skills from manifest to agent CLIs."
106
- )
107
- parser.add_argument(
108
- "--scope",
109
- choices=["global", "project"],
110
- required=True,
111
- help="Whether to sync global skills or project-local skills.",
112
- )
113
- parser.add_argument(
114
- "--prune-retired",
115
- action="store_true",
116
- help=(
117
- "Remove managed symlinks left behind in retired CLI skill "
118
- "directories. Without this flag they are only reported."
119
- ),
120
- )
121
- parser.add_argument(
122
- "--no-reconcile",
123
- action="store_true",
124
- help=(
125
- "Keep managed projection links this sync did not declare. By "
126
- "default a projection is reconciled: a symlink into a managed "
127
- "registry root that no declared skill or pack accounts for is "
128
- "removed, because leaving it is what turns a projection into "
129
- "sediment (81 of pjangler's 132 links were dangling)."
130
- ),
131
- )
132
- parser.add_argument(
133
- "--reconcile-dry-run",
134
- action="store_true",
135
- help=(
136
- "Print the links reconcile WOULD remove and remove nothing. Run "
137
- "this first against any projection that has never been synced -- "
138
- "notably --scope global, whose projection is shared by every CLI "
139
- "and every project on the machine. Suppresses deletions only: "
140
- "links are still created and relinked."
141
- ),
142
- )
143
- parser.add_argument(
144
- "--root",
145
- default=os.environ.get("MISE_CONFIG_ROOT") or None,
146
- help=(
147
- "Project root this sync owns; defaults to $MISE_CONFIG_ROOT. Never "
148
- "cwd: a parent mise config's enter hook runs with cwd set to the "
149
- "child directory you entered, so cwd names a sibling repo."
150
- ),
151
- )
152
- return parser.parse_args()
153
-
154
-
155
- def own_root():
156
- """The repo this script file belongs to: <root>/.mise/scripts/<this>."""
157
- return pathlib.Path(__file__).resolve().parents[2]
158
-
159
-
160
- def resolved_project_root(args):
161
- mine = own_root()
162
- if args.scope != "project":
163
- return mine
164
- if not args.root:
165
- raise SystemExit(
166
- "sync-skills: --root (or $MISE_CONFIG_ROOT) is required for "
167
- "--scope project; refusing to infer the subject repo from cwd"
168
- )
169
- requested = pathlib.Path(args.root).resolve(strict=True)
170
- if requested != mine:
171
- raise SystemExit(
172
- f"sync-skills: refusing to act on {requested}; this script belongs "
173
- f"to {mine}. A nested repo must ship its own .mise/scripts copy."
174
- )
175
- return requested
176
-
177
-
178
- def load_manifest(manifest_path):
179
- if not manifest_path.exists():
180
- return {"skills": []}
181
- with open(manifest_path, "r") as handle:
182
- return json.load(handle)
183
-
184
-
185
- def validate_skill_name(name):
186
- if not isinstance(name, str) or not name:
187
- raise ValueError("Skill name must be a non-empty string")
188
- if name in {".", ".."} or Path(name).is_absolute():
189
- raise ValueError(f"Unsafe skill name: {name!r}")
190
- if "/" in name or "\\" in name or Path(name).name != name:
191
- raise ValueError(f"Skill name must be one path component: {name!r}")
192
- return name
193
-
194
-
195
- def manifest_skill_name(skill):
196
- if isinstance(skill, str):
197
- path = skill if "/" in skill else f"all-skills/{skill}"
198
- return validate_skill_name(path.split("/")[-1])
199
- if not isinstance(skill, dict):
200
- raise ValueError(f"Skill entry must be a string or object: {skill!r}")
201
- return validate_skill_name(skill.get("name"))
202
-
203
-
204
- def validate_manifest_skill_names(manifest):
205
- skills = manifest.get("skills", [])
206
- if not isinstance(skills, list):
207
- raise ValueError("Manifest skills must be an array")
208
- for skill in skills:
209
- manifest_skill_name(skill)
210
-
211
-
212
- def validate_manifest_packs(manifest):
213
- """Normalize + validate every `packs[]` entry without touching the disk."""
214
- packs = manifest.get("packs", [])
215
- if packs is None:
216
- return []
217
- if not isinstance(packs, list):
218
- raise ValueError("Manifest packs must be an array")
219
- return [normalize_pack_entry(entry) for entry in packs]
220
-
221
-
222
- def assert_real_directory_chain(root, target):
223
- root = root.resolve(strict=True)
224
- target = target.absolute()
225
- try:
226
- relative = target.relative_to(root)
227
- except ValueError as error:
228
- raise ValueError(f"Destination escapes root {root}: {target}") from error
229
- current = root
230
- for part in relative.parts:
231
- current = current / part
232
- if not current.exists() and not current.is_symlink():
233
- break
234
- if current.is_symlink():
235
- raise ValueError(f"Refusing symlinked destination directory: {current}")
236
- if not current.is_dir():
237
- raise ValueError(f"Destination parent is not a directory: {current}")
238
-
239
-
240
- def alias_root_exclusions():
241
- """Managed roots that may NOT host the projection directory itself.
242
-
243
- `~/.agents/.cache` is a managed root for reconcile purposes, but
244
- `sync_registry()` CLONES and PULLS arbitrary remote git repositories into it.
245
- A projection living inside one would be rewritten by whoever controls the
246
- registry, so the alias may only land in a checkout the operator owns and this
247
- engine never fetches into.
248
- """
249
- return (Path(os.path.expanduser("~/.agents/.cache")),)
250
-
251
-
252
- def canonical_alias_roots():
253
- """The roots the single managed-skills alias may resolve into."""
254
- excluded = set()
255
- for candidate in alias_root_exclusions():
256
- try:
257
- excluded.add(candidate.resolve(strict=True))
258
- except OSError:
259
- continue
260
- return {root for root in default_managed_roots() if root not in excluded}
261
-
262
-
263
- def assert_safe_resolved_chain(root, resolved):
264
- """Every component from `root` down to `resolved` is a real, safe directory.
265
-
266
- Both arguments are already realpath-normalized, so this is a plain lstat walk:
267
- no component is a symlink, each is a directory, each is owned by this user,
268
- and none is world-writable.
269
-
270
- Group-writability is deliberately ALLOWED. The canonical checkout is 0775
271
- under the operator's own primary group, so a "no group-write" rule would
272
- refuse the very topology this exists to admit. World-write is what actually
273
- hands the path to someone else.
274
- """
275
- uid = os.getuid() if hasattr(os, "getuid") else None
276
- try:
277
- relative = resolved.relative_to(root)
278
- except ValueError as error:
279
- raise ValueError(f"Resolved alias escapes its managed root {root}: {resolved}") from error
280
- current = root
281
- for part in relative.parts:
282
- current = current / part
283
- info = current.lstat()
284
- if stat.S_ISLNK(info.st_mode):
285
- raise ValueError(f"Resolved alias chain contains a symlink: {current}")
286
- if not stat.S_ISDIR(info.st_mode):
287
- raise ValueError(f"Resolved alias chain component is not a directory: {current}")
288
- if uid is not None and info.st_uid != uid:
289
- raise ValueError(f"Resolved alias chain component is not owned by this user: {current}")
290
- if info.st_mode & stat.S_IWOTH:
291
- raise ValueError(f"Resolved alias chain component is world-writable: {current}")
292
-
293
-
294
- def resolve_managed_alias(alias, allowed_roots):
295
- """Resolve the ONE admissible symlinked projection, or refuse.
296
-
297
- Every containment assertion in this engine is LEXICAL -- `symlink_target.parent
298
- != real_cli_dir`, `assert_destinations_contained`. A lexical guard is sound
299
- only over a symlink-free path, which is why `assert_real_directory_chain`
300
- refused any symlinked destination outright.
301
-
302
- That refusal made `--scope global` unrunnable, because the canonical topology
303
- IS a symlink: `~/.agents/skills -> ~/code/skillex/skill-sets/global`. The
304
- relaxation admits exactly that shape and nothing else -- the alias must be a
305
- symlink owned by this user that resolves, with no further symlink in the
306
- chain, into a managed registry root the operator owns. Callers then address
307
- the RESOLVED path for every mutation, so re-pointing the alias mid-run cannot
308
- redirect a write.
309
- """
310
- if not allowed_roots:
311
- raise ValueError(f"No managed registry root is available to host {alias}")
312
- info = alias.lstat()
313
- uid = os.getuid() if hasattr(os, "getuid") else None
314
- if uid is not None and info.st_uid != uid:
315
- raise ValueError(f"Managed skills alias is not owned by this user: {alias}")
316
- resolved = alias.resolve(strict=True)
317
- for root in sorted(allowed_roots, key=lambda candidate: len(str(candidate)), reverse=True):
318
- try:
319
- resolved.relative_to(root)
320
- except ValueError:
321
- continue
322
- assert_safe_resolved_chain(root, resolved)
323
- return resolved
324
- raise ValueError(
325
- f"Managed skills alias does not resolve into a managed registry root: "
326
- f"{alias} -> {resolved}"
327
- )
328
-
329
- def cli_skill_dirs(scope):
330
- try:
331
- return CLI_SKILL_DIRS[scope]
332
- except KeyError as error:
333
- raise ValueError(f"Unknown scope: {scope!r}") from error
334
-
335
-
336
- def managed_skills_dir(base):
337
- return base.joinpath(*MANAGED_SKILLS_RELATIVE)
338
-
339
-
340
- def lexical_symlink_target(link):
341
- """Lexically expand a symlink WITHOUT resolving it.
342
-
343
- Resolving an arbitrary directory symlink here would let the cleanup below
344
- traverse outside the project (or through a broken target) before failing.
345
- """
346
- raw_target = Path(os.readlink(link))
347
- lexical = raw_target if raw_target.is_absolute() else link.parent / raw_target
348
- return Path(os.path.normpath(str(lexical)))
349
-
350
-
351
- def assert_nonrecursive_skill_link(destination, source):
352
- """Reject a link that would point from inside a skill back to that skill.
353
-
354
- A repository may legitimately be the source of a globally distributed
355
- skill. When fanout runs inside that same repository, however, projecting
356
- ``<repo>/.claude/skills/<name> -> <repo>`` creates an unbounded filesystem
357
- cycle for every symlink-following walker. Compare the lexical destination
358
- against the resolved source so the check also catches a catalog alias that
359
- points at the repository root.
360
-
361
- The one safe equality is an existing real directory in the shared managed
362
- projection. That directory is already in its final location and the
363
- caller preserves it instead of creating a symlink.
364
- """
365
- destination = Path(destination).absolute()
366
- source = Path(source).resolve(strict=True)
367
- if destination == source and is_real_directory(destination):
368
- return
369
- try:
370
- destination.relative_to(source)
371
- except ValueError:
372
- return
373
- raise ValueError(
374
- "Refusing recursive skill symlink: destination "
375
- f"{destination} is inside its resolved source {source}"
376
- )
377
-
378
-
379
- def assert_nonrecursive_skill_topology(active_cli_dirs, skill_sources):
380
- """Validate every proposed source/destination pair before any mutation."""
381
- for _cli_dir, expected_cli in active_cli_dirs:
382
- for name, source in skill_sources.items():
383
- assert_nonrecursive_skill_link(expected_cli / validate_skill_name(name), source)
384
-
385
-
386
- def preflight_cli_dirs(
387
- cli_dirs_base,
388
- skill_names,
389
- scope="project",
390
- skill_sources=None,
391
- ):
392
- # Re-validate here rather than trusting the caller: this is the guard that
393
- # decides where mutations may land, and `expected_cli / ".."` would satisfy
394
- # the containment check below on its own.
395
- skill_names = [validate_skill_name(name) for name in skill_names]
396
- base = cli_dirs_base.resolve(strict=True)
397
- active = []
398
- claimed = set()
399
-
400
- def assert_destinations_contained(expected_cli):
401
- for name in skill_names:
402
- destination = expected_cli / name
403
- if destination.parent != expected_cli or len(destination.relative_to(expected_cli).parts) != 1:
404
- raise ValueError(f"Skill destination escapes CLI directory: {destination}")
405
-
406
- def add_target(cli_dir, expected_cli):
407
- assert_destinations_contained(expected_cli)
408
- # Every CLI that aliases `.agents/skills` names the SAME destination.
409
- # Project into it once; a second pass would only churn its own links.
410
- if expected_cli in claimed:
411
- return
412
- claimed.add(expected_cli)
413
- active.append((cli_dir, expected_cli))
414
-
415
- for cli_rel_path in cli_skill_dirs(scope):
416
- cli_dir = base / cli_rel_path
417
- parent = cli_dir.parent
418
- if not parent.exists() and not parent.is_symlink():
419
- continue
420
- assert_real_directory_chain(base, parent)
421
- if parent.is_symlink() or not parent.is_dir():
422
- raise ValueError(f"Unsafe CLI destination parent: {parent}")
423
- if cli_dir.is_symlink():
424
- # Generated projects intentionally expose the single managed skill
425
- # projection to the agent CLIs as `<cli>/skills -> .agents/skills`.
426
- # Accept only that exact lexical alias (relative or absolute form).
427
- managed_skills = managed_skills_dir(base)
428
- canonical_alias = lexical_symlink_target(cli_dir) == managed_skills
429
- if not canonical_alias:
430
- raise ValueError(f"Refusing symlinked CLI skills directory: {cli_dir}")
431
- assert_real_directory_chain(base, managed_skills.parent)
432
- # In the canonical Skillex topology the projection directory IS the
433
- # registry checkout, reached through a symlink
434
- # (`~/.agents/skills -> ~/code/skillex/skill-sets/global`), so
435
- # demanding a real directory here made `--scope global` unrunnable on
436
- # the only machine that has the topology. Admit exactly that shape
437
- # through resolve_managed_alias() and nothing else.
438
- if managed_skills.is_symlink():
439
- if scope != "global":
440
- # A generated PROJECT always owns a real `.agents/skills` --
441
- # pjangler's TypeScript mirror (projectSkillTopologyIssues in
442
- # src/parity/rules.ts) says so too. Relaxing only the global
443
- # scope keeps every project destination on the strict rule.
444
- raise ValueError(
445
- f"Managed skills alias target is not a real directory: {managed_skills}"
446
- )
447
- resolved_alias = resolve_managed_alias(managed_skills, canonical_alias_roots())
448
- else:
449
- if (
450
- not managed_skills.exists()
451
- or not managed_skills.is_dir()
452
- ):
453
- raise ValueError(
454
- f"Managed skills alias target is not a real directory: {managed_skills}"
455
- )
456
- assert_real_directory_chain(base, managed_skills)
457
- resolved_alias = managed_skills.resolve(strict=True)
458
- resolved_cli = cli_dir.resolve(strict=True)
459
- if resolved_cli != resolved_alias:
460
- raise ValueError(
461
- f"Managed skills alias escapes managed project skills: {cli_dir}"
462
- )
463
- # The alias makes `.agents/skills` this CLI's skills directory, so
464
- # that IS where this CLI's projection has to land. Skipping it
465
- # instead would silently drop every `skills[]` entry on a project
466
- # where every supported CLI is aliased -- `provision-packs.py`
467
- # only ever materializes pack members.
468
- # Pin the RESOLVED directory as the expected destination. Every later
469
- # mutation then addresses the real path directly instead of
470
- # re-traversing the alias, so re-pointing the alias mid-run cannot
471
- # redirect a write; revalidate_cli_dir() re-checks it still resolves
472
- # here before each one.
473
- managed_expected = resolved_alias
474
- # Containment first, so an escaping name is reported as an escape
475
- # rather than as whatever it happens to collide with.
476
- assert_destinations_contained(managed_expected)
477
- # `.agents/skills` is shared with the pack provisioner and may hold
478
- # real, hand-authored skill directories. Never let the fanout
479
- # rmtree one of those; fail here, before anything is mutated.
480
- for name in skill_names:
481
- destination = resolved_alias / name
482
- if not is_real_directory(destination):
483
- continue
484
- source = skill_sources.get(name) if skill_sources is not None else None
485
- if source is not None and destination.resolve(strict=True) == Path(source).resolve(strict=True):
486
- # A project-local skill may deliberately live in the
487
- # canonical managed directory. Every supported CLI aliases
488
- # this same projection, so the directory is already in its
489
- # final location and must remain real.
490
- continue
491
- if skill_sources is not None:
492
- raise ValueError(
493
- "Refusing to replace a real skill directory in the managed "
494
- f"projection: {destination}"
495
- )
496
- add_target(managed_skills, managed_expected)
497
- continue
498
- else:
499
- if cli_dir.exists() and not cli_dir.is_dir():
500
- raise ValueError(f"CLI skills destination is not a directory: {cli_dir}")
501
- real_parent = parent.resolve(strict=True)
502
- expected_cli = real_parent / cli_dir.name
503
- if cli_dir.exists() and cli_dir.resolve(strict=True) != expected_cli:
504
- raise ValueError(f"CLI skills directory escapes its parent: {cli_dir}")
505
- add_target(cli_dir, expected_cli)
506
- if skill_sources is not None:
507
- assert_nonrecursive_skill_topology(active, skill_sources)
508
- return active
509
-
510
-
511
- def revalidate_cli_dir(cli_dirs_base, cli_dir, expected_cli):
512
- """Revalidate the complete destination chain at the mutation boundary."""
513
- base = cli_dirs_base.resolve(strict=True)
514
- assert_real_directory_chain(base, cli_dir.parent)
515
- if cli_dir.parent.is_symlink() or not cli_dir.parent.is_dir():
516
- raise ValueError(f"Unsafe CLI destination parent after preflight: {cli_dir.parent}")
517
- if cli_dir.is_symlink():
518
- # Exactly ONE symlinked destination is admissible -- the canonical
519
- # managed-skills alias -- and only while it still resolves to the very
520
- # directory preflight pinned as `expected_cli`. `expected_cli != cli_dir`
521
- # is what tells the global alias (pinned to its RESOLVED target) apart
522
- # from a project's real `.agents/skills`, where the two are the same
523
- # path; a project destination that turned into a symlink after preflight
524
- # therefore still fails here.
525
- if cli_dir != managed_skills_dir(base) or expected_cli == cli_dir:
526
- raise ValueError(f"CLI skills directory changed to a symlink after preflight: {cli_dir}")
527
- if resolve_managed_alias(cli_dir, canonical_alias_roots()) != expected_cli:
528
- raise ValueError(f"Managed skills alias re-pointed after preflight: {cli_dir}")
529
- return
530
- current_expected = cli_dir.parent.resolve(strict=True) / cli_dir.name
531
- if current_expected != expected_cli:
532
- raise ValueError(f"CLI destination parent changed after preflight: {cli_dir}")
533
- if cli_dir.exists():
534
- if not cli_dir.is_dir() or cli_dir.resolve(strict=True) != expected_cli:
535
- raise ValueError(f"Unsafe CLI skills directory after preflight: {cli_dir}")
536
-
537
-
538
- def ensure_cache_dir():
539
- cache_dir = Path(os.path.expanduser("~/.agents/.cache/skills"))
540
- cache_dir.mkdir(parents=True, exist_ok=True)
541
- return cache_dir
542
-
543
-
544
- def registry_cache_dir(registry_url):
545
- """Where a registry URL is cloned to / read back from.
546
-
547
- The directory NAME is a wire format shared with two other surfaces that
548
- address this exact directory on this exact machine:
549
-
550
- * skillex ``src/skillex/paths.py`` -> ``sanitize_registry_url()``
551
- * pjangler ``src/parity/index.ts`` -> ``registryCacheDirName()``
552
-
553
- If they disagree, one manifest resolves to two different checkouts on one
554
- machine and one of them is an unverified stale clone. This script is the
555
- only surface allowed to CLONE, so it owns the name and the others follow.
556
- Every non-alphanumeric byte becomes ``_``, so the result is always exactly
557
- one safe path component - no separator, no ``.``, no ``..``.
558
-
559
- Do not change this regex here alone.
560
- """
561
- safe_name = re.sub(r"[^a-zA-Z0-9]", "_", registry_url)
562
- if not safe_name:
563
- # Would collapse to the registries/ parent and hand back the cache dir
564
- # itself as if it were a checkout.
565
- raise ValueError(f"Registry URL has no usable cache directory name: {registry_url!r}")
566
- return Path(os.path.expanduser("~/.agents/.cache/registries")) / safe_name
567
-
568
-
569
- def sync_registry(registry_url):
570
- cache_dir = registry_cache_dir(registry_url)
571
-
572
- if cache_dir.exists():
573
- try:
574
- print(f"Updating registry {registry_url}...")
575
- subprocess.run(
576
- ["git", "-C", str(cache_dir), "pull"],
577
- check=True,
578
- capture_output=True,
579
- )
580
- except subprocess.CalledProcessError as error:
581
- print(
582
- f"Warning: Failed to update registry {registry_url}: "
583
- f"{error.stderr.decode()}",
584
- file=sys.stderr,
585
- )
586
- else:
587
- cache_dir.parent.mkdir(parents=True, exist_ok=True)
588
- print(f"Cloning registry {registry_url}...")
589
- subprocess.run(["git", "clone", registry_url, str(cache_dir)], check=True)
590
-
591
- return cache_dir
592
-
593
-
594
- def sync_git_skill(name, source, version, cache_dir):
595
- target_dir = cache_dir / name
596
- if target_dir.exists():
597
- # Just pull if it exists
598
- try:
599
- print(f"Updating git skill {name} in cache...")
600
- subprocess.run(
601
- ["git", "-C", str(target_dir), "pull"],
602
- check=True,
603
- capture_output=True,
604
- )
605
- except subprocess.CalledProcessError as error:
606
- print(
607
- f"Warning: Failed to update {name}: {error.stderr.decode()}",
608
- file=sys.stderr,
609
- )
610
- else:
611
- print(f"Cloning git skill {name} to cache...")
612
- subprocess.run(["git", "clone", source, str(target_dir)], check=True)
613
-
614
- if version:
615
- subprocess.run(
616
- ["git", "-C", str(target_dir), "checkout", version], check=True
617
- )
618
-
619
- return target_dir
620
-
621
-
622
- def resolve_skill_path(
623
- skill, cache_dir, base_dir, default_registry, registry_roots
624
- ):
625
- if isinstance(skill, str):
626
- path = skill if "/" in skill else f"all-skills/{skill}"
627
- name = validate_skill_name(path.split("/")[-1])
628
- skill = {"name": name, "registry_path": path}
629
-
630
- name = validate_skill_name(skill.get("name"))
631
-
632
- if "registry_path" in skill:
633
- registry_url = skill.get("registry", default_registry)
634
- # Walk the same stable, memoized checkout ladder as packs[]. A path
635
- # absent from the cache may legitimately exist in the developer
636
- # checkout; existing checkouts are never fetched as a side effect.
637
- attempted = []
638
- for root in registry_root_candidates(registry_url, registry_roots):
639
- full_path = root / skill["registry_path"]
640
- attempted.append(full_path)
641
- if full_path.exists():
642
- return name, full_path
643
- print(
644
- f"Warning: Registry skill {name} not found in: {attempted}",
645
- file=sys.stderr,
646
- )
647
- return name, None
648
-
649
- source = skill.get("source", "")
650
- if source.startswith("git@") or source.startswith("https://"):
651
- return name, sync_git_skill(
652
- name, source, skill.get("version"), cache_dir
653
- )
654
- parsed = urlparse(source)
655
- if parsed.scheme == "file":
656
- if parsed.netloc not in {"", "localhost"}:
657
- raise ValueError(f"Non-local file URI authority for skill {name}: {parsed.netloc}")
658
- if parsed.query or parsed.fragment:
659
- raise ValueError(f"file:// source must encode query/fragment characters: {source}")
660
- local_path = Path(unquote(parsed.path))
661
- full_path = (base_dir / local_path).resolve()
662
- if not full_path.exists():
663
- print(
664
- f"Warning: Local skill {name} not found at {full_path}",
665
- file=sys.stderr,
666
- )
667
- return name, None
668
- return name, full_path
669
-
670
- print(
671
- f"Warning: Unknown source type for skill {name}: {source}",
672
- file=sys.stderr,
673
- )
674
- return name, None
675
-
676
-
677
- # --------------------------------------------------------------------------- #
678
- # Packs
679
- # --------------------------------------------------------------------------- #
680
-
681
-
682
- def validate_path_component(value, label):
683
- if not isinstance(value, str) or not value:
684
- raise ValueError(f"{label} must be a non-empty string")
685
- if value in {".", ".."} or Path(value).is_absolute():
686
- raise ValueError(f"Unsafe {label}: {value!r}")
687
- if "/" in value or "\\" in value or Path(value).name != value:
688
- raise ValueError(f"{label} must be one path component: {value!r}")
689
- return value
690
-
691
-
692
- def safe_relative_path(value, label):
693
- if not isinstance(value, str) or not value:
694
- raise ValueError(f"{label} must be a non-empty string")
695
- if "\\" in value:
696
- raise ValueError(f"{label} must not contain backslashes: {value!r}")
697
- path = Path(value)
698
- if path.is_absolute() or any(part in {"", ".", ".."} for part in path.parts):
699
- raise ValueError(f"Unsafe {label}: {value!r}")
700
- return path
701
-
702
-
703
- def manifest_entry_source_path(value):
704
- """The `file:` source of a `skills[]` entry as a normalized absolute path.
705
-
706
- LEXICAL on purpose: `os.path.abspath` normalizes without resolving symlinks,
707
- so this matches `provision-packs.py` and pjangler byte-for-byte. It is a pure
708
- predicate helper — it decides precedence, never safety, and never touches the
709
- filesystem. `None` for anything that is not a local `file:` source.
710
- """
711
- if not isinstance(value, dict):
712
- return None
713
- source = value.get("source")
714
- if not isinstance(source, str) or not source.startswith("file:"):
715
- return None
716
- try:
717
- parsed = urlparse(source)
718
- if parsed.netloc not in {"", "localhost"}:
719
- return None
720
- return Path(os.path.abspath(unquote(parsed.path)))
721
- except (OSError, ValueError):
722
- return None
723
-
724
-
725
- def is_contained_by(root, target):
726
- try:
727
- target.relative_to(root)
728
- except ValueError:
729
- return False
730
- return True
731
-
732
-
733
- class PackScope:
734
- """Declared-pack ownership for ONE manifest (PACKS-CONTRACT section 6).
735
-
736
- Section 6 pruning runs BEFORE the section 5 override: a `skills[]` entry a
737
- declared pack already provides is redundant, not an override. Keeping that
738
- decision here — and scoped to a single manifest — is what makes
739
- `sync-skills.py`, `provision-packs.py`, and `pj audit` resolve every name to
740
- the same path.
741
-
742
- Deliberately narrow. An entry only counts as redundant when its own resolved
743
- source lands inside the pack, or when it is a declared member pointing into
744
- another version of the same pack family. An entry pointing anywhere else — a
745
- local tree, a different registry, a customized copy — is the user's and is
746
- never pruned.
747
- """
748
-
749
- def __init__(self):
750
- self.roots = []
751
- self.family_roots = {}
752
-
753
- def record(self, resolved):
754
- """Absorb one `resolve_pack` `on_resolved` record."""
755
- root = resolved["root"]
756
- if root not in self.roots:
757
- self.roots.append(root)
758
- family_root = resolved.get("family_root")
759
- if family_root is not None:
760
- # `packs/<name>/<version>` -> `packs/<name>` is the pack family. Only
761
- # DECLARED names are attached: the family root is not the pack's own
762
- # extent, so a sibling version only shadows names this pack provides.
763
- # Under flatten those names are the FLATTENED ones — see
764
- # `resolve_pack` — which is what a `skills[]` entry would be named.
765
- self.family_roots.setdefault(family_root, set()).update(resolved["declared"])
766
-
767
- def is_redundant(self, skill_entry):
768
- source_path = manifest_entry_source_path(skill_entry)
769
- if source_path is None:
770
- return False
771
- # (a) the entry's own source lands inside a resolved pack root. Holds
772
- # unchanged under flatten: a hand-written entry pointing at the leaf
773
- # `<root>/apple/apple-notes` is contained by `<root>`, so the pack still
774
- # wins and the entry is still pruned.
775
- if any(is_contained_by(root, source_path) for root in self.roots):
776
- return True
777
- # (b) a declared member pointing into ANY version of the same pack.
778
- # Name lookup is TOLERANT here, matching pjangler's
779
- # `skillManifestEntryName`: redundancy is a precedence question, so a
780
- # malformed entry is simply "not redundant" rather than a hard failure.
781
- # The strict `validate_skill_name()` gate still runs before any symlink.
782
- name = skill_entry.get("name")
783
- if not isinstance(name, str):
784
- return False
785
- return any(
786
- name in members and is_contained_by(family, source_path)
787
- for family, members in self.family_roots.items()
788
- )
789
-
790
-
791
- def normalize_pack_entry(entry):
792
- """String shorthand or object -> validated object form."""
793
- if isinstance(entry, str):
794
- raw = entry.strip()
795
- if "@" in raw:
796
- name, _, version = raw.partition("@")
797
- entry = {"name": name, "version": version}
798
- else:
799
- entry = {"name": raw}
800
- if not isinstance(entry, dict):
801
- raise ValueError(f"Pack entry must be a string or object: {entry!r}")
802
-
803
- normalized = dict(entry)
804
- name = validate_skill_name(normalized.get("name"))
805
- if not PACK_NAME_PATTERN.match(name):
806
- print(
807
- f"Warning: pack name {name!r} does not match the canonical "
808
- f"{PACK_NAME_PATTERN.pattern} shape",
809
- file=sys.stderr,
810
- )
811
- normalized["name"] = name
812
-
813
- if normalized.get("version") is not None:
814
- normalized["version"] = validate_path_component(
815
- normalized["version"], f"pack {name} version"
816
- )
817
- if normalized.get("source") and normalized.get("registry_path"):
818
- raise ValueError(
819
- f"Pack {name} may not set both `source` and `registry_path`"
820
- )
821
- if normalized.get("source") is not None and not isinstance(normalized["source"], str):
822
- raise ValueError(f"Pack {name} source must be a string")
823
- if normalized.get("registry") is not None and not isinstance(normalized["registry"], str):
824
- raise ValueError(f"Pack {name} registry must be a string")
825
- if normalized.get("registry_path") is not None:
826
- safe_relative_path(normalized["registry_path"], f"pack {name} registry_path")
827
- for key in ("include", "exclude"):
828
- values = normalized.get(key)
829
- if values is None:
830
- continue
831
- if not isinstance(values, list):
832
- raise ValueError(f"Pack {name} {key} must be an array of skill names")
833
- for value in values:
834
- validate_skill_name(value)
835
- for key in ("optional", "sealed", "flatten"):
836
- if key in normalized and normalized[key] is not None and not isinstance(normalized[key], bool):
837
- raise ValueError(f"Pack {name} {key} must be a boolean")
838
- return normalized
839
-
840
-
841
- def version_sort_key(version):
842
- """PEP440/semver-ish ordering: numeric-segment aware, prereleases sort low."""
843
-
844
- def segments(text):
845
- parts = []
846
- for chunk in re.split(r"[._]", text):
847
- if not chunk:
848
- continue
849
- if chunk.isdigit():
850
- parts.append((0, int(chunk), ""))
851
- else:
852
- parts.append((1, 0, chunk))
853
- return tuple(parts)
854
-
855
- release, separator, prerelease = version.partition("-")
856
- return (
857
- segments(release),
858
- 1 if not separator else 0,
859
- segments(prerelease),
860
- )
861
-
862
-
863
- def read_regular_file(path):
864
- descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
865
- try:
866
- if not stat.S_ISREG(os.fstat(descriptor).st_mode):
867
- raise ValueError(f"Pack entry is not a regular file: {path}")
868
- with os.fdopen(os.dup(descriptor), "rb") as handle:
869
- return handle.read()
870
- finally:
871
- os.close(descriptor)
872
-
873
-
874
- def hash_regular_file(path):
875
- descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
876
- try:
877
- if not stat.S_ISREG(os.fstat(descriptor).st_mode):
878
- raise ValueError(f"Pack entry is not a regular file: {path}")
879
- digest = hashlib.sha256()
880
- with os.fdopen(os.dup(descriptor), "rb") as handle:
881
- for chunk in iter(lambda: handle.read(1 << 20), b""):
882
- digest.update(chunk)
883
- return digest.hexdigest()
884
- finally:
885
- os.close(descriptor)
886
-
887
-
888
- def is_real_directory(path):
889
- try:
890
- return stat.S_ISDIR(os.lstat(path).st_mode)
891
- except OSError:
892
- return False
893
-
894
-
895
- def is_regular_file(path):
896
- try:
897
- return stat.S_ISREG(os.lstat(path).st_mode)
898
- except OSError:
899
- return False
900
-
901
-
902
- def assert_real_pack_directory(path, label):
903
- try:
904
- mode = os.lstat(path).st_mode
905
- except OSError as error:
906
- raise PackUnavailable(f"{label} is not present: {path}") from error
907
- if stat.S_ISLNK(mode) or not stat.S_ISDIR(mode):
908
- raise ValueError(f"{label} must be a real directory: {path}")
909
- return path
910
-
911
-
912
- def assert_no_symlink_components(root, relative):
913
- current = Path(root)
914
- for part in Path(relative).parts:
915
- current = current / part
916
- try:
917
- mode = os.lstat(current).st_mode
918
- except FileNotFoundError as error:
919
- raise PackUnavailable(f"Pack path is not present: {current}") from error
920
- if stat.S_ISLNK(mode):
921
- raise ValueError(f"Refusing symlinked pack path component: {current}")
922
- return current
923
-
924
-
925
- def safe_checksum_path(value):
926
- path = Path(value)
927
- if (
928
- not value
929
- or "\\" in value
930
- or path.is_absolute()
931
- or any(part in {"", ".", ".."} for part in path.parts)
932
- ):
933
- raise ValueError(f"Unsafe checksum path: {value!r}")
934
- return path
935
-
936
-
937
- def scan_children(directory):
938
- with os.scandir(directory) as entries:
939
- return sorted(entries, key=lambda item: item.name)
940
-
941
-
942
- def registry_root_candidates(registry_url, roots_cache, allow_clone=True):
943
- """Existing registry checkouts for `registry_url`, in contract order.
944
-
945
- Candidate order is contract section 2 step 3:
946
- `PJ_SKILLS_REGISTRY_ROOT` | `~/.agents/.cache/registries/<sanitized-url>` |
947
- `~/code/skillex`.
948
-
949
- `packs[]` and `skills[]` share this resolver and its memo. Existing
950
- checkouts are never fetched: pinned packs must resolve offline and a sync
951
- must not mutate a checkout merely by reading it. When no checkout exists,
952
- this script may clone the registry once because it is the only surface
953
- authorized to do so.
954
- """
955
- if registry_url in roots_cache:
956
- return roots_cache[registry_url]
957
-
958
- def adopt(candidate):
959
- try:
960
- if candidate.is_dir():
961
- return candidate.resolve(strict=True)
962
- except OSError:
963
- pass
964
- return None
965
-
966
- override = os.environ.get("PJ_SKILLS_REGISTRY_ROOT", "").strip()
967
- if override:
968
- pinned = adopt(Path(override).expanduser())
969
- if pinned is None:
970
- raise PackUnavailable(
971
- f"Explicit registry checkout is not available for {registry_url}: {override}"
972
- )
973
- roots_cache[registry_url] = [pinned]
974
- return roots_cache[registry_url]
975
-
976
- roots = []
977
- for candidate in (registry_cache_dir(registry_url), Path(os.path.expanduser("~/code/skillex"))):
978
- existing = adopt(candidate)
979
- if existing is not None and existing not in roots:
980
- roots.append(existing)
981
-
982
- if not roots and allow_clone:
983
- try:
984
- roots.append(sync_registry(registry_url).resolve(strict=True))
985
- except (subprocess.CalledProcessError, OSError) as error:
986
- raise PackUnavailable(
987
- f"No registry checkout available for {registry_url}: {error}"
988
- ) from error
989
-
990
- if not roots:
991
- raise PackUnavailable(f"No registry checkout available for {registry_url}")
992
- roots_cache[registry_url] = roots
993
- return roots
994
-
995
-
996
- def registry_root(registry_url, roots_cache, allow_clone=True):
997
- """Compatibility helper for callers that need the first checkout only."""
998
- return registry_root_candidates(registry_url, roots_cache, allow_clone)[0]
999
-
1000
-
1001
- def select_pack_version(pack_dir):
1002
- """Highest version subdirectory, or None when this is not a version layout.
1003
-
1004
- "Only subdirectories" is necessary but NOT sufficient. A pack.toml-less
1005
- `packs/<name>/` whose children are REAL directories that each hold a regular
1006
- SKILL.md satisfies that test and is emphatically not a version layout -- it
1007
- is a flat pack, and the contract section 3 glob inventory applies instead.
1008
- The discriminator is what those children ARE: a child holding a regular
1009
- SKILL.md is a skill, so its parent cannot be a version root. Contrast
1010
- `packs/bmad/`, also pack.toml-less and also all real directories, but whose
1011
- children (1.2.0-next.3/, 1.3.0/) hold no top-level SKILL.md -- that IS a
1012
- version layout and the highest version is selected.
1013
-
1014
- `packs/Kurzgesagt/` is NOT an example of this: its twelve children are all
1015
- symlinks, so it is disqualified one check earlier by the S_ISDIR-on-lstat
1016
- test below and never reaches the SKILL.md test. (Earlier revisions of this
1017
- comment cited it as "twelve skill directories"; that was wrong.)
1018
- """
1019
- versions = []
1020
- for entry in scan_children(pack_dir):
1021
- if entry.name.startswith("."):
1022
- continue
1023
- if not stat.S_ISDIR(entry.stat(follow_symlinks=False).st_mode):
1024
- # Not a pure "only subdirectories" layout -> flat pack.
1025
- return None
1026
- if is_regular_file(Path(entry.path) / "SKILL.md"):
1027
- return None
1028
- versions.append(entry.name)
1029
- if not versions:
1030
- return None
1031
- return max(versions, key=version_sort_key)
1032
-
1033
-
1034
- def resolve_pack_root_in_registry(checkout_root, entry, registry_url):
1035
- """Resolve one pack inside one checkout, distinguishing absence from hostility."""
1036
- name = entry["name"]
1037
- version = entry.get("version")
1038
- if entry.get("registry_path"):
1039
- relative = safe_relative_path(entry["registry_path"], f"pack {name} registry_path")
1040
- else:
1041
- relative = Path("packs") / name
1042
- pack_dir = checkout_root / relative
1043
- assert_no_symlink_components(checkout_root, relative)
1044
- assert_real_pack_directory(pack_dir, f"Pack {name} directory")
1045
- if version:
1046
- relative = relative / version
1047
- elif not is_regular_file(pack_dir / "pack.toml"):
1048
- selected = select_pack_version(pack_dir)
1049
- if selected is not None:
1050
- relative = relative / selected
1051
-
1052
- root = checkout_root / relative
1053
- assert_no_symlink_components(checkout_root, relative)
1054
- assert_real_pack_directory(root, f"Pack {name} root")
1055
-
1056
- metadata = read_pack_metadata(root)
1057
- attested = metadata is not None
1058
- if metadata is not None:
1059
- pack = metadata.get("pack", {})
1060
- if not isinstance(pack, dict):
1061
- raise ValueError(f"Pack {name} pack.toml [pack] must be a table")
1062
- if pack.get("name") != name:
1063
- raise ValueError(f"Pack {name} pack.toml declares name {pack.get('name')!r}")
1064
- if version and pack.get("version") != version:
1065
- raise ValueError(
1066
- f"Pack {name} pack.toml declares version {pack.get('version')!r}, "
1067
- f"manifest pins {version!r}"
1068
- )
1069
- return root, f"{registry_url}:{relative.as_posix()}", attested
1070
-
1071
-
1072
- def resolve_pack_root(entry, base_dir, cache_dir, registry_roots, default_registry):
1073
- """(pack_root, description) for a normalized pack entry."""
1074
- name = entry["name"]
1075
- version = entry.get("version")
1076
- source = entry.get("source")
1077
-
1078
- if source:
1079
- if source.startswith("git@") or source.startswith("https://"):
1080
- root = sync_git_skill(name, source, version, cache_dir)
1081
- return assert_real_pack_directory(Path(root).absolute(), f"Pack {name} root"), source
1082
- parsed = urlparse(source)
1083
- if parsed.scheme != "file":
1084
- raise ValueError(f"Unknown pack source type for {name}: {source}")
1085
- if parsed.netloc not in {"", "localhost"}:
1086
- raise ValueError(f"Non-local file URI authority for pack {name}: {parsed.netloc}")
1087
- if parsed.query or parsed.fragment:
1088
- raise ValueError(f"file:// pack source must encode query/fragment characters: {source}")
1089
- local_path = Path(unquote(parsed.path))
1090
- root = (base_dir / local_path).absolute()
1091
- return assert_real_pack_directory(root, f"Pack {name} root"), source
1092
-
1093
- registry_url = entry.get("registry", default_registry)
1094
- matches = []
1095
- first_unavailable = None
1096
- for checkout_root in registry_root_candidates(registry_url, registry_roots):
1097
- try:
1098
- matches.append(resolve_pack_root_in_registry(checkout_root, entry, registry_url))
1099
- except PackUnavailable as error:
1100
- if first_unavailable is None:
1101
- first_unavailable = error
1102
- if not matches:
1103
- raise first_unavailable or PackUnavailable(
1104
- f"Pack {name} is unavailable in every registry checkout for {registry_url}"
1105
- )
1106
- # A positively identified pack outranks a same-shaped but unattested tree;
1107
- # contract order still breaks ties. Present-but-hostile metadata raises in
1108
- # resolve_pack_root_in_registry and is never bypassed.
1109
- root, description, _attested = next(
1110
- (match for match in matches if match[2]), matches[0]
1111
- )
1112
- return root, description
1113
-
1114
-
1115
- def read_pack_metadata(root):
1116
- try:
1117
- raw = read_regular_file(root / "pack.toml")
1118
- except FileNotFoundError:
1119
- return None
1120
- try:
1121
- return tomllib.loads(raw.decode("utf-8"))
1122
- except (UnicodeDecodeError, tomllib.TOMLDecodeError) as error:
1123
- raise ValueError(f"Pack metadata at {root / 'pack.toml'} does not parse: {error}") from error
1124
-
1125
-
1126
- def pack_flatten_enabled(metadata, entry):
1127
- """Contract section 3b: is this pack projected through a flatten expansion?
1128
-
1129
- Enabled when EITHER `pack.toml [policy] flatten = true` OR the manifest pack
1130
- entry sets `"flatten": true`. Layout is a property of the PACK, so pack.toml
1131
- is the natural home; the manifest field exists for packs that ship no
1132
- pack.toml. Neither side can turn the other OFF — this is a union, not an
1133
- override, because a nested pack projected flat-side-up would produce
1134
- container directories where the CLIs expect skills.
1135
-
1136
- OFF unless someone says otherwise: every pack that predates 3b keeps its
1137
- exact section 3 inventory.
1138
- """
1139
- if entry.get("flatten") is True:
1140
- return True
1141
- policy = metadata.get("policy", {}) if isinstance(metadata, dict) else {}
1142
- return isinstance(policy, dict) and policy.get("flatten") is True
1143
-
1144
-
1145
- def container_leaves(container):
1146
- """Every skill reachable under `container`, at ANY depth (contract 3b).
1147
-
1148
- Returns `(leaves, symlinked)`, both lists of POSIX-style paths relative to
1149
- `container`, in walk order.
1150
-
1151
- The descent rule is the whole of the expansion: descend while a node is a
1152
- container; a node holding a regular SKILL.md IS a skill and is never
1153
- descended into. That mirrors upstream `agent/skill_utils.py`'s
1154
- `iter_skill_index_files`, a depth-agnostic `os.walk`, and it is why
1155
- hermes-base's `mlops/evaluation/lm-evaluation-harness` IS a member —
1156
- `mlops/evaluation` carries only a DESCRIPTION.md, so it is another container
1157
- and the walk continues through it.
1158
-
1159
- Stopping at the first SKILL.md on each branch is also what keeps a skill's own
1160
- references/, scripts/, assets/ and templates/ subtree from contributing a
1161
- second member — the same reason upstream prunes SKILL_SUPPORT_DIRS only under
1162
- a directory that already has a SKILL.md.
1163
-
1164
- Skip rules match section 3's glob exactly, at every level. Symlinks are never
1165
- followed, so the descent cannot cycle.
1166
-
1167
- Deliberately iterative, not recursive. Nesting depth here is a property of a
1168
- directory tree this process did not create, and Python's recursion limit
1169
- (~1000 frames) is FAR below the depth a filesystem accepts — PATH_MAX allows
1170
- roughly 2000 levels of two-character segments. A recursive walk raises
1171
- `RecursionError` on such a tree, which is a hard crash of `sync-skills.py`
1172
- rather than a finding, and it would disagree with the two surfaces that do
1173
- resolve it (skillex's `flatten_inventory` and pjangler's `packContainerLeaves`
1174
- both handle it). `frames` is an explicit stack of partially-consumed child
1175
- iterators, so it reproduces the recursive pre-order EXACTLY — a container's
1176
- subtree is emitted in the position the container itself occupied — while the
1177
- depth it can carry is bounded by the heap instead of the C stack.
1178
- """
1179
- leaves = []
1180
- symlinked = []
1181
- # Each frame is (prefix, iterator over that directory's sorted children).
1182
- frames = [("", iter(scan_children(container)))]
1183
-
1184
- while frames:
1185
- prefix, children = frames[-1]
1186
- descended = False
1187
- for child in children:
1188
- if child.name.startswith(".") or child.name.startswith("_"):
1189
- continue
1190
- relative = f"{prefix}/{child.name}" if prefix else child.name
1191
- mode = child.stat(follow_symlinks=False).st_mode
1192
- if stat.S_ISLNK(mode):
1193
- symlinked.append(relative)
1194
- continue
1195
- if not stat.S_ISDIR(mode):
1196
- continue
1197
- child_path = Path(child.path)
1198
- # A symlinked SKILL.md is not a regular file, so the leaf is skipped.
1199
- if is_regular_file(child_path / "SKILL.md"):
1200
- leaves.append(relative)
1201
- continue
1202
- # Still a container: keep descending. Suspending THIS frame mid-iterator
1203
- # is what preserves pre-order; the loop resumes it once the child's
1204
- # subtree is exhausted.
1205
- frames.append((relative, iter(scan_children(child_path))))
1206
- descended = True
1207
- break
1208
- if not descended:
1209
- frames.pop()
1210
-
1211
- return leaves, symlinked
1212
-
1213
-
1214
- def has_flattenable_children(directory):
1215
- """True when a skill is reachable ANYWHERE under `directory`.
1216
-
1217
- The discriminator between "a CONTAINER of skills" and "an ordinary directory
1218
- that happens to sit in the pack root" (docs/, assets/, ...). Used only on the
1219
- section 3 GLOB path, where a container has to be recognized without a
1220
- pack.toml to declare it.
1221
-
1222
- Written in terms of `container_leaves` so the glob's notion of "container" can
1223
- never drift from what the expansion actually reaches: a directory that
1224
- qualifies here always contributes at least one member, and one that does not
1225
- could never have contributed any.
1226
- """
1227
- leaves, _ = container_leaves(directory)
1228
- return bool(leaves)
1229
-
1230
-
1231
- def flatten_pack_inventory(root, name, declared):
1232
- """Contract section 3b: expand a declared inventory by DESCENT.
1233
-
1234
- Returns an ordered list of `(projected_name, relative_path)`. The projected
1235
- name is the LEAF basename; the relative path is where that leaf actually
1236
- lives, which under flatten is no longer `<root>/<name>` — `apple-notes`
1237
- resolves to `<root>/apple/apple-notes` and `vllm` to
1238
- `<root>/mlops/inference/vllm`. The path may be ANY number of segments deep, so
1239
- every caller has to carry the pair and never rebuild the path from the name.
1240
-
1241
- Container-level files (Hermes' `DESCRIPTION.md`) are NOT projected, at any
1242
- level. They stay in the pack and remain part of the sealed payload, because
1243
- the payload is still "every file under each DECLARED entry" and the container
1244
- is what was declared. Sealing is completely unaffected by this expansion.
1245
- """
1246
- inventory = []
1247
- seen = {}
1248
-
1249
- def claim(leaf_name, relative, declared_as_is=False):
1250
- """Project one leaf. Returns True when it was actually claimed.
1251
-
1252
- `declared_as_is` marks the one case where the name was NOT lifted off
1253
- the filesystem: a declared entry that is already a skill keeps the
1254
- author's string, exactly as it would without flatten. It defaults to
1255
- False so the canonical gate below is the fail-safe direction for any
1256
- future call site.
1257
- """
1258
- if not declared_as_is and not CANONICAL_NAME_PATTERN.match(leaf_name):
1259
- # Contract 3b. Flatten is the ONLY place a projected skill name is
1260
- # lifted straight off the filesystem — without it a pack.toml pack
1261
- # projects exactly the strings its author typed into
1262
- # `[freeform].skills`. `validate_skill_name()` only asks for one
1263
- # safe path component, which happily admits `-rf`, `--help`, `*`,
1264
- # and names carrying newlines or tabs; those become argv- and
1265
- # glob-hostile symlink names in all six CLI skill directories.
1266
- # Skipped rather than raised so one odd upstream directory cannot
1267
- # brick a whole pack. `!r` keeps control characters escaped in the
1268
- # warning itself.
1269
- print(
1270
- f"Warning: pack {name} leaf {leaf_name!r} at {relative} is not a canonical "
1271
- f"skill name ({CANONICAL_NAME_PATTERN.pattern}); skipping",
1272
- file=sys.stderr,
1273
- )
1274
- return False
1275
- validate_skill_name(leaf_name)
1276
- previous = seen.get(leaf_name)
1277
- if previous is not None:
1278
- # Ambiguous pack: two leaves would project onto one CLI destination,
1279
- # and which one won would depend on inventory order. Refuse rather
1280
- # than silently pick. (Across packs this is fine — section 5
1281
- # precedence decides — but within ONE pack there is no rule to
1282
- # apply.)
1283
- raise ValueError(
1284
- f"Pack {name} flattens to a duplicate skill name {leaf_name!r}: "
1285
- f"{root / previous} and {root / relative}"
1286
- )
1287
- seen[leaf_name] = relative
1288
- inventory.append((leaf_name, relative))
1289
- return True
1290
-
1291
- for entry_name in declared:
1292
- container = root / entry_name
1293
- assert_real_pack_directory(container, f"Pack skill {entry_name}")
1294
- if is_regular_file(container / "SKILL.md"):
1295
- # Already a leaf. Taken as-is, exactly as without flatten — the name
1296
- # is the author's declared string, not a filesystem basename.
1297
- claim(entry_name, Path(entry_name), declared_as_is=True)
1298
- continue
1299
-
1300
- # A CONTAINER. Descend it to ANY depth: the walk stops on each branch at
1301
- # the first directory carrying a SKILL.md, so a container of containers
1302
- # (hermes-base's `mlops/`) resolves the way upstream's depth-agnostic
1303
- # `iter_skill_index_files` does.
1304
- leaves, symlinked = container_leaves(container)
1305
- for relative in symlinked:
1306
- # Defence in depth: `walk_pack_subtree` already refuses symlinks
1307
- # anywhere in the payload, so a symlinked leaf normally fails
1308
- # verification before this runs.
1309
- print(
1310
- f"Warning: pack {name} member {entry_name}/{relative} is a symlink; skipping",
1311
- file=sys.stderr,
1312
- )
1313
- contributed = 0
1314
- for relative in leaves:
1315
- # NAME is the leaf basename; PATH may be any number of segments deep.
1316
- if claim(
1317
- relative.rsplit("/", 1)[-1],
1318
- Path(entry_name).joinpath(*relative.split("/")),
1319
- ):
1320
- contributed += 1
1321
- if contributed == 0:
1322
- # A container whose ENTIRE subtree yields no PROJECTABLE skill —
1323
- # either it holds none at all, or every leaf it holds was rejected
1324
- # by the canonical-name gate. Reported, never silently dropped.
1325
- print(
1326
- f"Warning: pack {name} declared entry {entry_name!r} is a container "
1327
- f"that contributes no skills",
1328
- file=sys.stderr,
1329
- )
1330
- return inventory
1331
-
1332
-
1333
- def pack_inventory(root, name, declared, flatten):
1334
- """The projected inventory as ordered `(name, relative_path)` pairs.
1335
-
1336
- Without flatten a member is `<root>/<name>` exactly as before; with flatten
1337
- it is the section 3b expansion. Everything downstream — include/exclude,
1338
- section 6 redundancy, the symlink target — consumes this one shape.
1339
- """
1340
- if flatten:
1341
- return flatten_pack_inventory(root, name, declared)
1342
- return [(item, Path(item)) for item in declared]
1343
-
1344
-
1345
- def pack_declared_skills(root, metadata, entry, flatten=False):
1346
- """The full declared inventory (pre-include/exclude).
1347
-
1348
- Under flatten these are the DECLARED entries, which may be containers rather
1349
- than skills — the section 3b expansion into projected skill names happens
1350
- later, in `pack_inventory()`. Keeping the two apart is what leaves sealing
1351
- untouched: the payload is defined over what is DECLARED.
1352
- """
1353
- name = entry["name"]
1354
- if metadata is not None:
1355
- pack = metadata.get("pack", {})
1356
- if not isinstance(pack, dict):
1357
- raise ValueError(f"Pack {name} pack.toml [pack] must be a table")
1358
- if pack.get("name") != name:
1359
- raise ValueError(
1360
- f"Pack {name} pack.toml declares name {pack.get('name')!r}"
1361
- )
1362
- if entry.get("version") and pack.get("version") != entry["version"]:
1363
- raise ValueError(
1364
- f"Pack {name} pack.toml declares version {pack.get('version')!r}, "
1365
- f"manifest pins {entry['version']!r}"
1366
- )
1367
- freeform = metadata.get("freeform", {})
1368
- if not isinstance(freeform, dict):
1369
- raise ValueError(f"Pack {name} pack.toml [freeform] must be a table")
1370
- declared = freeform.get("skills", [])
1371
- if not isinstance(declared, list) or not all(isinstance(item, str) for item in declared):
1372
- raise ValueError(f"Pack {name} pack.toml [freeform].skills must be an array of strings")
1373
- if len(set(declared)) != len(declared):
1374
- raise ValueError(f"Pack {name} pack.toml declares duplicate skills")
1375
- for item in declared:
1376
- validate_skill_name(item)
1377
- return list(declared)
1378
-
1379
- declared = []
1380
- for child in scan_children(root):
1381
- if child.name.startswith(".") or child.name.startswith("_"):
1382
- continue
1383
- mode = child.stat(follow_symlinks=False).st_mode
1384
- if stat.S_ISLNK(mode):
1385
- print(
1386
- f"Warning: pack {name} member {child.name!r} is a symlink; skipping",
1387
- file=sys.stderr,
1388
- )
1389
- continue
1390
- if not stat.S_ISDIR(mode):
1391
- continue
1392
- if not is_regular_file(Path(child.path) / "SKILL.md"):
1393
- # Section 3 stops here: no SKILL.md, not a skill. Under flatten a
1394
- # SKILL.md-less child may instead be a CONTAINER, and skipping it
1395
- # would make the manifest's `"flatten": true` a no-op for exactly
1396
- # the pack.toml-less packs it exists to serve. The extra condition
1397
- # keeps ordinary directories (docs/, assets/) out of the inventory —
1398
- # and therefore out of the sealed payload.
1399
- if not (flatten and has_flattenable_children(Path(child.path))):
1400
- continue
1401
- declared.append(validate_skill_name(child.name))
1402
- return declared
1403
-
1404
-
1405
- def walk_pack_subtree(root, relative_root, files, directories):
1406
- def visit(directory):
1407
- for child in scan_children(directory):
1408
- path = Path(child.path)
1409
- relative = path.relative_to(root).as_posix()
1410
- mode = child.stat(follow_symlinks=False).st_mode
1411
- if stat.S_ISLNK(mode):
1412
- raise ValueError(f"Pack payload may not contain symlinks: {path}")
1413
- if stat.S_ISDIR(mode):
1414
- directories.add(relative)
1415
- visit(path)
1416
- elif stat.S_ISREG(mode):
1417
- files[relative] = hash_regular_file(path)
1418
- else:
1419
- raise ValueError(
1420
- f"Pack payload may contain only regular files and directories: {path}"
1421
- )
1422
-
1423
- directories.add(Path(relative_root).as_posix())
1424
- visit(root / relative_root)
1425
-
1426
-
1427
- def pack_payload(root, metadata, declared, flatten=False):
1428
- """payload = pack.toml + every file under each DECLARED skill directory.
1429
-
1430
- UNCHANGED by contract section 3b: declaring a container already covers its
1431
- leaves recursively, so a flattened pack seals and verifies byte-for-byte the
1432
- same as before. The one concession is the SKILL.md requirement below — under
1433
- flatten a declared entry is allowed to be a container, and demanding a
1434
- SKILL.md at its root would reject the very layout 3b exists to project.
1435
- """
1436
- files = {}
1437
- directories = set()
1438
- if metadata is not None:
1439
- files["pack.toml"] = hash_regular_file(root / "pack.toml")
1440
- for name in declared:
1441
- skill_dir = root / name
1442
- assert_real_pack_directory(skill_dir, f"Pack skill {name}")
1443
- if not flatten and not is_regular_file(skill_dir / "SKILL.md"):
1444
- raise PackUnavailable(
1445
- f"Pack skill {name} is missing a regular SKILL.md: {skill_dir}"
1446
- )
1447
- walk_pack_subtree(root, name, files, directories)
1448
- return files, directories
1449
-
1450
-
1451
- def parse_checksums(root):
1452
- raw = read_regular_file(root / "SHA256SUMS")
1453
- expected = {}
1454
- for line in raw.decode("utf-8").splitlines():
1455
- if not line:
1456
- continue
1457
- digest, separator, value = line.partition(" ")
1458
- if (
1459
- separator != " "
1460
- or len(digest) != 64
1461
- or any(char not in "0123456789abcdef" for char in digest)
1462
- ):
1463
- raise ValueError(f"Invalid SHA256SUMS entry in {root}: {line}")
1464
- relative = safe_checksum_path(value).as_posix()
1465
- if relative in expected:
1466
- raise ValueError(f"Duplicate SHA256SUMS entry in {root}: {relative}")
1467
- expected[relative] = digest
1468
- return expected
1469
-
1470
-
1471
- def verify_sealed_pack(root, files, directories):
1472
- expected = parse_checksums(root)
1473
-
1474
- missing = sorted(set(files) - set(expected))
1475
- if missing:
1476
- raise ValueError(
1477
- f"Pack payload at {root} is not covered by SHA256SUMS: {missing[:5]}"
1478
- )
1479
- for relative, digest in files.items():
1480
- if expected[relative] != digest:
1481
- raise ValueError(f"Pack digest mismatch at {root}: {relative}")
1482
-
1483
- for relative, digest in sorted(expected.items()):
1484
- if relative in files:
1485
- continue
1486
- path = safe_checksum_path(relative)
1487
- try:
1488
- assert_no_symlink_components(root, path)
1489
- actual = hash_regular_file(root / path)
1490
- except (FileNotFoundError, PackUnavailable) as error:
1491
- # `assert_no_symlink_components` reports an absent component as
1492
- # PackUnavailable. Inside a seal it is nothing of the sort: rule 3
1493
- # says a checksummed path may not be absent, so this is an
1494
- # integrity failure and must never be `optional`-suppressed.
1495
- raise ValueError(
1496
- f"SHA256SUMS at {root} references a missing path: {relative}"
1497
- ) from error
1498
- if actual != digest:
1499
- raise ValueError(f"Pack digest mismatch at {root}: {relative}")
1500
-
1501
- covered = set()
1502
- for relative in files:
1503
- parts = relative.split("/")
1504
- for index in range(1, len(parts)):
1505
- covered.add("/".join(parts[:index]))
1506
- unauthenticated = sorted(directory for directory in directories if directory not in covered)
1507
- if unauthenticated:
1508
- raise ValueError(
1509
- f"Pack at {root} contains unauthenticated empty directories: {unauthenticated[:5]}"
1510
- )
1511
-
1512
-
1513
- def verify_pack(root, metadata, declared, sealed, flatten=False):
1514
- """Structural validation always; checksum verification when sealed.
1515
-
1516
- A seal is a COMPLETENESS claim: the pack asserts that exactly this payload,
1517
- and every path its SHA256SUMS names, is present with these digests. So a
1518
- sealed pack that is missing a declared skill, a SKILL.md, or a checksummed
1519
- file has failed integrity verification -- it is not "an uninstalled pack".
1520
- Nothing raised from here while `sealed` may therefore be a PackUnavailable,
1521
- because that class (and only that class) is what `"optional": true`
1522
- downgrades to a warning in resolve_pack().
1523
- """
1524
- try:
1525
- files, directories = pack_payload(root, metadata, declared, flatten=flatten)
1526
- if metadata is not None:
1527
- payload_files = metadata.get("source", {}).get("payload_files")
1528
- if isinstance(payload_files, int):
1529
- actual = sum(1 for relative in files if relative != "pack.toml")
1530
- if actual != payload_files:
1531
- raise ValueError(
1532
- f"Pack at {root} declares {payload_files} payload files but has {actual}"
1533
- )
1534
- if sealed:
1535
- if not is_regular_file(root / "SHA256SUMS"):
1536
- raise ValueError(f"Sealed pack at {root} has no regular SHA256SUMS")
1537
- verify_sealed_pack(root, files, directories)
1538
- except PackUnavailable as error:
1539
- if not sealed:
1540
- # Unsealed packs get structural validation only, and a missing
1541
- # member there really is "not installed here" -- still optional.
1542
- raise
1543
- raise ValueError(
1544
- f"Sealed pack at {root} failed integrity verification: {error}"
1545
- ) from error
1546
- return files
1547
-
1548
-
1549
- def resolve_pack(
1550
- entry,
1551
- cache_dir,
1552
- base_dir,
1553
- default_registry,
1554
- registry_roots,
1555
- managed_roots,
1556
- on_resolved=None,
1557
- ):
1558
- """Resolve one normalized pack entry to an ordered list of (name, path).
1559
-
1560
- `on_resolved`, when given, is called once per pack that resolved AND
1561
- verified, with a record describing WHERE the pack came from:
1562
-
1563
- {"name", "root", "family_root", "declared"}
1564
-
1565
- `root` is the exact pack root (e.g. `packs/bmad/1.3.0`); `family_root` is
1566
- `packs/bmad` when the pack lives under a version directory, else None. The
1567
- two are reported SEPARATELY on purpose: a sibling version under the same
1568
- family root is NOT this pack, so callers must never treat the family root
1569
- as the pack's own extent. `declared` is the full inventory before
1570
- include/exclude, so a caller can tell "this pack provides that name" from
1571
- "some other version of this pack does".
1572
-
1573
- Under flatten (contract 3b) `declared` carries the FLATTENED names, because
1574
- that is what the pack PROVIDES and section 6 clause (b) asks what a pack
1575
- provides. The container names it was declared with are an implementation
1576
- detail of the pack's on-disk layout, and no `skills[]` entry is ever named
1577
- after one. Section 6 clause (a) is unaffected: a leaf at
1578
- `<root>/apple/apple-notes` is still contained by `<root>`.
1579
-
1580
- Returned paths are LEAF paths and are no longer necessarily `<root>/<name>`.
1581
- """
1582
- name = entry["name"]
1583
- optional = bool(entry.get("optional", False))
1584
- try:
1585
- root, description = resolve_pack_root(
1586
- entry, base_dir, cache_dir, registry_roots, default_registry
1587
- )
1588
-
1589
- family_root = root.parent if root.parent.name == name else None
1590
- managed_roots.add(root)
1591
- if family_root is not None:
1592
- # `packs/<name>/<version>` -> the whole family is a managed root.
1593
- managed_roots.add(family_root)
1594
-
1595
- metadata = read_pack_metadata(root)
1596
- flatten = pack_flatten_enabled(metadata, entry)
1597
- declared = pack_declared_skills(root, metadata, entry, flatten=flatten)
1598
-
1599
- sealed = bool(entry.get("sealed", False))
1600
- policy = metadata.get("policy", {}) if isinstance(metadata, dict) else {}
1601
- if isinstance(policy, dict) and policy.get("sealed") is True:
1602
- # The manifest may only TIGHTEN: `sealed: false` cannot disable this.
1603
- sealed = True
1604
-
1605
- verify_pack(root, metadata, declared, sealed, flatten=flatten)
1606
- # Expansion runs INSIDE the try, and strictly after verification: an
1607
- # integrity check must never be gated on how a pack is projected, and a
1608
- # member that vanished between the two is `optional`-suppressible here
1609
- # exactly as it is above.
1610
- inventory = pack_inventory(root, name, declared, flatten)
1611
- except PackUnavailable as error:
1612
- if optional:
1613
- print(f"Warning: optional pack {name} skipped: {error}", file=sys.stderr)
1614
- return []
1615
- raise ValueError(f"Pack {name} could not be resolved: {error}") from error
1616
-
1617
- provided = [item for item, _relative in inventory]
1618
- if on_resolved is not None:
1619
- on_resolved(
1620
- {
1621
- "name": name,
1622
- "root": root,
1623
- "family_root": family_root,
1624
- "declared": list(provided),
1625
- }
1626
- )
1627
-
1628
- # include/exclude apply AFTER expansion, to the FLATTENED names (contract
1629
- # 3b): a manifest names the skills it wants, and under flatten a container
1630
- # name is not one of them.
1631
- members = list(inventory)
1632
- include = entry.get("include")
1633
- if include is not None:
1634
- wanted = set(include)
1635
- unknown = sorted(wanted - set(provided))
1636
- if unknown:
1637
- print(
1638
- f"Warning: pack {name} include names not in inventory: {unknown}",
1639
- file=sys.stderr,
1640
- )
1641
- members = [item for item in members if item[0] in wanted]
1642
- exclude = entry.get("exclude")
1643
- if exclude:
1644
- unwanted = set(exclude)
1645
- members = [item for item in members if item[0] not in unwanted]
1646
-
1647
- print(
1648
- f"pack {name}"
1649
- + (f"@{entry['version']}" if entry.get("version") else "")
1650
- + f" -> {root} "
1651
- f"({len(members)}/{len(inventory)} skill(s), "
1652
- f"{'sealed' if sealed else 'unsealed'}, "
1653
- + (f"flattened from {len(declared)} declared entries, " if flatten else "")
1654
- + f"via {description})"
1655
- )
1656
- return [
1657
- (validate_skill_name(item), root / relative) for item, relative in members
1658
- ]
1659
-
1660
-
1661
- # --------------------------------------------------------------------------- #
1662
- # Retired directories
1663
- # --------------------------------------------------------------------------- #
1664
-
1665
-
1666
- def default_managed_roots():
1667
- roots = set()
1668
- for candidate in (
1669
- Path(os.path.expanduser("~/.agents/.cache")),
1670
- Path(os.path.expanduser("~/code/skillex")),
1671
- ):
1672
- try:
1673
- if candidate.is_dir():
1674
- roots.add(candidate.resolve(strict=True))
1675
- except OSError:
1676
- continue
1677
- override = os.environ.get("PJ_SKILLS_REGISTRY_ROOT", "").strip()
1678
- if override:
1679
- try:
1680
- roots.add(Path(override).expanduser().resolve(strict=True))
1681
- except OSError:
1682
- pass
1683
- return roots
1684
-
1685
-
1686
- def is_inside_managed_root(target, managed_roots):
1687
- for root in managed_roots:
1688
- try:
1689
- Path(target).relative_to(root)
1690
- return True
1691
- except ValueError:
1692
- continue
1693
- return False
1694
-
1695
-
1696
- def handle_retired_dirs(cli_dirs_base, managed_roots, prune=False):
1697
- base = cli_dirs_base.resolve(strict=True)
1698
- candidates = []
1699
- for cli_rel_path in RETIRED_CLI_SKILL_DIRS:
1700
- if cli_rel_path in NEVER_PRUNE_DIRS:
1701
- continue
1702
- retired = base / cli_rel_path
1703
- if not retired.exists() and not retired.is_symlink():
1704
- continue
1705
- assert_real_directory_chain(base, retired.parent)
1706
- if retired.is_symlink():
1707
- print(
1708
- f"sync-skills: retired {retired} is a symlink; leaving it alone",
1709
- file=sys.stderr,
1710
- )
1711
- continue
1712
- if not retired.is_dir():
1713
- continue
1714
- real_retired = retired.resolve(strict=True)
1715
- if real_retired != retired.parent.resolve(strict=True) / retired.name:
1716
- raise ValueError(f"Retired skills directory escapes its parent: {retired}")
1717
- for child in scan_children(real_retired):
1718
- if not stat.S_ISLNK(child.stat(follow_symlinks=False).st_mode):
1719
- continue
1720
- target = os.path.realpath(child.path)
1721
- if not is_inside_managed_root(target, managed_roots):
1722
- continue
1723
- candidates.append((Path(child.path), target))
1724
-
1725
- if not candidates:
1726
- return 0
1727
-
1728
- if not prune:
1729
- print(
1730
- f"sync-skills: {len(candidates)} managed symlink(s) remain in retired "
1731
- f"CLI skill dirs under {base}; re-run with --prune-retired to remove them:"
1732
- )
1733
- for link, target in candidates:
1734
- print(f" would prune {link} -> {target}")
1735
- return 0
1736
-
1737
- pruned = 0
1738
- for link, target in candidates:
1739
- parent = link.parent
1740
- assert_real_directory_chain(base, parent)
1741
- if parent.is_symlink() or not parent.is_dir():
1742
- raise ValueError(f"Retired skills parent changed after preflight: {parent}")
1743
- try:
1744
- mode = os.lstat(link).st_mode
1745
- except FileNotFoundError:
1746
- continue
1747
- if not stat.S_ISLNK(mode):
1748
- raise ValueError(f"Retired entry is no longer a symlink: {link}")
1749
- os.unlink(link)
1750
- pruned += 1
1751
- print(f"pruned {link} -> {target}")
1752
- print(f"sync-skills: pruned {pruned} retired symlink(s) under {base}")
1753
- return pruned
1754
-
1755
-
1756
- # --------------------------------------------------------------------------- #
1757
- # Fanout
1758
- # --------------------------------------------------------------------------- #
1759
-
1760
-
1761
- def reconcile_projection(real_cli_dir, skills_map, managed_roots, project_root=None, apply=True):
1762
- """Remove managed projection links this sync does not account for.
1763
-
1764
- `fanout_to_cli` only ever added and overwrote. Nothing removed a link, so a
1765
- projection was a monotonically growing record of every skill the repo ever
1766
- declared: rename a skill, retire a pack, or move a registry cache and the
1767
- old link stayed forever, dangling. Measured on the reporting machine: 81 of
1768
- pjangler's 132 `.claude/skills` links pointed at BMAD pack versions that no
1769
- longer exist, and a hand-planted broken link -- including one whose name IS
1770
- a declared entry -- survived a re-run untouched.
1771
-
1772
- Deliberately narrow. Only a SYMLINK is ever removed, and only when its
1773
- LEXICAL target (never a resolved one -- resolving a hostile link would walk
1774
- outside the project first) lies inside a managed registry root. So:
1775
-
1776
- - a real directory is never touched: that is BMAD's installer output in
1777
- `<cli>/skills`, or a hand-authored skill in `.agents/skills`;
1778
- - a link pointing anywhere outside the managed roots is the operator's
1779
- own and is left alone;
1780
- - a pack member survives, because `skills_map` already contains every
1781
- name the declared packs resolved to before fanout runs.
1782
- """
1783
- removed = []
1784
- try:
1785
- entries = sorted(os.listdir(real_cli_dir))
1786
- except OSError:
1787
- return removed
1788
- for name in entries:
1789
- if name in skills_map:
1790
- continue
1791
- candidate = real_cli_dir / name
1792
- if not candidate.is_symlink():
1793
- continue
1794
- try:
1795
- target = lexical_symlink_target(candidate)
1796
- except OSError:
1797
- continue
1798
- dangling = not candidate.exists()
1799
- # A DANGLING link is removed wherever it points. It names a skill and
1800
- # resolves to nothing, so it cannot be serving anyone, and the managed-root
1801
- # test would miss exactly the ones that hurt most: the relics of a retired
1802
- # intermediate hop. `<repo>/.claude/skills/hindsight ->
1803
- # 33GOD/skills/hindsight` outlived that farm entry and is not inside any
1804
- # registry root, so it would have rotted here forever. A link that still
1805
- # resolves is only removed inside a managed root, where this engine is the
1806
- # only writer.
1807
- # A live link is removable inside a managed registry root -- and inside the
1808
- # PROJECT ROOT itself, which is equally this engine's territory. Seven
1809
- # links in pjangler pointed at `<repo>/skills/<name>` for names the global
1810
- # scope already provides: leftovers from the era when inherit_global
1811
- # materialized the whole global manifest into every project. They resolve,
1812
- # so the managed-root test left them, and they are precisely the duplicate
1813
- # copy this engine now exists to stop making. A repo's own skill is not
1814
- # caught by this: discovery puts it in skills_map, so it is declared.
1815
- own_territory = is_inside_managed_root(target, managed_roots)
1816
- if not own_territory and project_root is not None:
1817
- own_territory = is_inside_managed_root(target, {Path(project_root).resolve()})
1818
- if not dangling and not own_territory:
1819
- continue
1820
- state = "dangling" if dangling else "undeclared"
1821
- if apply:
1822
- candidate.unlink()
1823
- removed.append((candidate, target, state))
1824
- print(f"{'✗' if apply else 'would remove'} {candidate} ({state} -> {target})")
1825
- return removed
1826
-
1827
- def fanout_to_cli(
1828
- cli_dirs_base,
1829
- skills_map,
1830
- active_cli_dirs=None,
1831
- before_mutation=None,
1832
- scope="project",
1833
- managed_roots=None,
1834
- reconcile=True,
1835
- reconcile_apply=True,
1836
- ):
1837
- """
1838
- Creates symlinks in each of the supported CLI skill dirs relative to
1839
- cli_dirs_base pointing to the resolved paths in skills_map.
1840
- """
1841
- skill_names = [validate_skill_name(name) for name in skills_map]
1842
- if active_cli_dirs is None:
1843
- active_cli_dirs = preflight_cli_dirs(
1844
- cli_dirs_base,
1845
- skill_names,
1846
- scope,
1847
- skill_sources=skills_map,
1848
- )
1849
- if skill_names and not active_cli_dirs:
1850
- # A sync that resolves skills but has nowhere to put them has FAILED.
1851
- # Reporting success here is how a topology change silently unprojects
1852
- # every skill; make it loud instead.
1853
- raise ValueError(
1854
- f"No supported agent CLI skills directory exists under {cli_dirs_base}; "
1855
- f"refusing to silently drop {len(skill_names)} skill(s): "
1856
- f"{sorted(skill_names)[:5]}"
1857
- )
1858
- if before_mutation is not None:
1859
- before_mutation()
1860
- linked_total = 0
1861
- for cli_dir, expected_cli in active_cli_dirs:
1862
- revalidate_cli_dir(cli_dirs_base, cli_dir, expected_cli)
1863
- if not cli_dir.is_symlink():
1864
- cli_dir.mkdir(parents=True, exist_ok=True)
1865
- revalidate_cli_dir(cli_dirs_base, cli_dir, expected_cli)
1866
- real_cli_dir = expected_cli.resolve(strict=True)
1867
-
1868
- for name, actual_path in skills_map.items():
1869
- revalidate_cli_dir(cli_dirs_base, cli_dir, expected_cli)
1870
- symlink_target = real_cli_dir / name
1871
- if symlink_target.parent != real_cli_dir:
1872
- raise ValueError(f"Skill destination escapes CLI directory: {symlink_target}")
1873
- assert_nonrecursive_skill_link(symlink_target, actual_path)
1874
-
1875
- # A project-local source may already be the exact destination in
1876
- # the shared `.agents/skills` projection. Preserve that real
1877
- # directory; replacing it with a self-referential symlink would
1878
- # destroy the source.
1879
- if (
1880
- is_real_directory(symlink_target)
1881
- and symlink_target.resolve(strict=True) == Path(actual_path).resolve(strict=True)
1882
- ):
1883
- continue
1884
-
1885
- # If it's a symlink already pointing to the right place, skip
1886
- if (
1887
- symlink_target.is_symlink()
1888
- and os.readlink(symlink_target) == str(actual_path)
1889
- ):
1890
- continue
1891
-
1892
- # If it exists but is wrong, remove it
1893
- if symlink_target.exists() or symlink_target.is_symlink():
1894
- revalidate_cli_dir(cli_dirs_base, cli_dir, expected_cli)
1895
- if symlink_target.is_dir() and not symlink_target.is_symlink():
1896
- shutil.rmtree(symlink_target)
1897
- else:
1898
- symlink_target.unlink()
1899
-
1900
- revalidate_cli_dir(cli_dirs_base, cli_dir, expected_cli)
1901
- assert_nonrecursive_skill_link(symlink_target, actual_path)
1902
- os.symlink(actual_path, symlink_target)
1903
- linked_total += 1
1904
- print(f"→ {symlink_target} -> {actual_path}")
1905
-
1906
- removed_total = 0
1907
- if reconcile and managed_roots:
1908
- for cli_dir, expected_cli in active_cli_dirs:
1909
- revalidate_cli_dir(cli_dirs_base, cli_dir, expected_cli)
1910
- removed_total += len(
1911
- reconcile_projection(
1912
- expected_cli.resolve(strict=True),
1913
- skills_map,
1914
- managed_roots,
1915
- # `cli_dirs_base` is the REPO at project scope -- and $HOME at
1916
- # global scope, where "inside the project root" would mean
1917
- # "anywhere under $HOME", i.e. every skill source on the
1918
- # machine. That is not a narrow rule, it is no rule: the
1919
- # global farm would reclaim every undeclared link it holds.
1920
- # Global scope keeps the managed-root test only.
1921
- project_root=None if scope == "global" else cli_dirs_base,
1922
- apply=reconcile_apply,
1923
- )
1924
- )
1925
-
1926
- print(
1927
- f"sync-skills: {linked_total} new/updated symlink(s), "
1928
- f"{removed_total} stale link(s) "
1929
- + ("removed " if reconcile_apply else "to remove (dry run) ")
1930
- + f"across CLIs in {cli_dirs_base}"
1931
- )
1932
-
1933
-
1934
- def report_global_inheritance(global_manifest_path):
1935
- """Verify that the user scope is reachable; project NOTHING from it.
1936
-
1937
- `inherit_global: true` used to prepend the whole global manifest as layer 0
1938
- of a PROJECT run, so every global skill was materialized as a fresh symlink
1939
- inside every project CLI skill dir -- 38 resolvable global skills x2 present
1940
- CLI dirs = 76 links per project, re-created on every `cd`.
1941
-
1942
- That work is dead. Every agent CLI installed on a machine like this one
1943
- reads the user scope implicitly, and each of its global skill roots is
1944
- already a single dir-level symlink to ~/.agents/skills:
1945
-
1946
- ~/.claude/skills ~/.codex/skills ~/.gemini/skills ~/.copilot/skills
1947
- ~/.kimi-code/skills ~/.config/opencode/skills ~/.openclaw/skills
1948
-
1949
- One projection, N aliases -- so a global skill is visible in a project
1950
- because the CLI inherits the user scope, not because someone copied a link
1951
- into the repo. A project projection holds only that repo's own declared
1952
- skills and its declared packs.
1953
-
1954
- What is left here is the check: if the user-scope alias is missing or points
1955
- somewhere else, global skills are NOT reachable and the operator should hear
1956
- about it rather than have this script quietly paper over it with copies.
1957
- """
1958
- home = Path(os.path.expanduser("~"))
1959
- managed = managed_global_skills_dir(home)
1960
- reachable, broken = [], []
1961
- for cli_rel_path in cli_skill_dirs("global"):
1962
- alias = home / cli_rel_path
1963
- if not alias.exists() and not alias.is_symlink():
1964
- continue
1965
- try:
1966
- if alias.resolve(strict=True) == managed.resolve(strict=True):
1967
- reachable.append(cli_rel_path)
1968
- continue
1969
- except OSError:
1970
- pass
1971
- broken.append(cli_rel_path)
1972
- # This runs from an enter hook on every `cd`, so say nothing when the user
1973
- # scope is intact. Report only what an operator has to act on.
1974
- if not broken and reachable:
1975
- return
1976
- declared = len(load_manifest(global_manifest_path).get("skills", []))
1977
- for cli_rel_path in broken:
1978
- print(
1979
- f"Warning: ~/{cli_rel_path} does not resolve to {managed}; global "
1980
- f"skills are not reachable for that CLI. Fix the alias "
1981
- f"(ln -sfn {managed} ~/{cli_rel_path}); do not copy links into "
1982
- f"projects.",
1983
- file=sys.stderr,
1984
- )
1985
- if not reachable:
1986
- print(
1987
- f"Warning: no user-scope alias of {managed} was found, so none of "
1988
- f"the {declared} global skill(s) declared in {global_manifest_path} "
1989
- f"are reachable from any CLI.",
1990
- file=sys.stderr,
1991
- )
1992
-
1993
-
1994
- def managed_global_skills_dir(home):
1995
- """The single user-scope projection every global CLI root aliases."""
1996
- return home.joinpath(*MANAGED_SKILLS_RELATIVE)
1997
-
1998
- def manifest_layer(manifest_path):
1999
- manifest = load_manifest(manifest_path)
2000
- validate_manifest_skill_names(manifest)
2001
- packs = validate_manifest_packs(manifest)
2002
- return {
2003
- "manifest": manifest,
2004
- "packs": packs,
2005
- "base_dir": manifest_path.parent,
2006
- "registry": manifest.get("registry", DEFAULT_REGISTRY),
2007
- }
2008
-
2009
-
2010
- def repo_local_skill_layer(project_root, inherited_names):
2011
- """Project `<repo>/skills/<name>/SKILL.md` without anyone declaring it.
2012
-
2013
- "Repo-specific skills live in the repo" is only true if the repo's own
2014
- skills reach an agent working in it. They did not: pjangler authored
2015
- `pjangler-dev` and `pjangler-parity-rules` and projected neither into any CLI
2016
- directory at any scope, so the skills describing how to develop pjangler were
2017
- invisible to every agent developing pjangler.
2018
-
2019
- Discovered rather than declared, on purpose. `.agents/skills.json` is
2020
- generated and gitignored in these repos, so a hand-written entry there does
2021
- not survive a fresh clone; and a declaration that merely restates the
2022
- contents of a directory is a second copy of the truth that can drift from the
2023
- first. A directory holding a SKILL.md IS the declaration.
2024
-
2025
- A name the GLOBAL scope already provides is skipped: every CLI inherits the
2026
- user scope implicitly, so projecting it here would be the copy this engine
2027
- exists to stop making. That is why pjangler gets exactly its two repo-only
2028
- skills and not the seven it authors for machine-wide use.
2029
-
2030
- Lowest precedence. A declared pack or an explicit skills[] entry of the same
2031
- name still wins, because explicit beats discovered.
2032
- """
2033
- skills_root = project_root / "skills"
2034
- if not skills_root.is_dir() or skills_root.is_symlink():
2035
- return None
2036
- entries = []
2037
- for child in sorted(skills_root.iterdir()):
2038
- if child.name.startswith("."):
2039
- continue
2040
- if child.is_symlink() or not child.is_dir():
2041
- continue
2042
- if not (child / "SKILL.md").is_file():
2043
- continue
2044
- name = validate_skill_name(child.name)
2045
- if name in inherited_names:
2046
- continue
2047
- entries.append({"name": name, "source": child.resolve(strict=True).as_uri()})
2048
- if not entries:
2049
- return None
2050
- print(f"Discovered {len(entries)} repo-local skill(s) in {skills_root}")
2051
- return {
2052
- "manifest": {"skills": entries},
2053
- "packs": [],
2054
- "base_dir": project_root,
2055
- "registry": DEFAULT_REGISTRY,
2056
- }
2057
-
2058
- def main():
2059
- args = parse_args()
2060
-
2061
- project_root = resolved_project_root(args)
2062
- global_manifest_path = Path(os.path.expanduser("~/.agents/skills.json"))
2063
- project_manifest_path = project_root / ".agents" / "skills.json"
2064
-
2065
- # Destination topology is a security boundary. Validate every active CLI
2066
- # directory before cloning/updating registries, creating caches, or changing
2067
- # any skill link so one unsafe/broken symlink produces zero mutation.
2068
- #
2069
- # Precedence, lowest to highest (contract section 5):
2070
- # global packs[] -> global skills[] -> <repo>/skills discovery
2071
- # -> project packs[] -> project skills[]
2072
- # Discovery sits below the project manifest: explicit beats discovered.
2073
- layers = []
2074
- if args.scope == "global":
2075
- preflight_base = Path(os.path.expanduser("~"))
2076
- print(f"Loading global manifest from {global_manifest_path}")
2077
- layers.append(manifest_layer(global_manifest_path))
2078
- else:
2079
- preflight_base = project_root
2080
- print(f"Loading project manifest from {project_manifest_path}")
2081
- project_layer = manifest_layer(project_manifest_path)
2082
- inherited_names = set()
2083
- if project_layer["manifest"].get("inherit_global", False):
2084
- report_global_inheritance(global_manifest_path)
2085
- inherited_names = {
2086
- manifest_skill_name(skill)
2087
- for skill in load_manifest(global_manifest_path).get("skills", [])
2088
- }
2089
- discovered = repo_local_skill_layer(project_root, inherited_names)
2090
- if discovered is not None:
2091
- layers.append(discovered)
2092
- layers.append(project_layer)
2093
-
2094
- preflight_names = []
2095
- for layer in layers:
2096
- preflight_names.extend(
2097
- manifest_skill_name(skill)
2098
- for skill in layer["manifest"].get("skills", [])
2099
- )
2100
- preflight_cli_dirs(preflight_base, preflight_names, args.scope)
2101
-
2102
- cache_dir = ensure_cache_dir()
2103
-
2104
- skills_to_sync = {} # name -> actual_path
2105
- # ONE stable memo of registry URL -> ordered checkout roots, shared by
2106
- # `packs[]` and `skills[]`. Existing roots are never refreshed mid-run.
2107
- registry_roots = {}
2108
- managed_roots = default_managed_roots()
2109
-
2110
- for layer in layers:
2111
- # Section 6 redundancy is scoped to ONE manifest: a project `skills[]`
2112
- # entry is weighed only against packs the project manifest declares,
2113
- # never against the global manifest's packs. This is exactly the scope
2114
- # provision-packs.py and `pj audit` use, so all three surfaces resolve
2115
- # every name to the same path. Cross-layer precedence still comes from
2116
- # the 1-4 ordering above, so a project pack does override a global
2117
- # `skills[]` entry.
2118
- pack_scope = PackScope()
2119
- for entry in layer["packs"]:
2120
- for name, path in resolve_pack(
2121
- entry,
2122
- cache_dir,
2123
- layer["base_dir"],
2124
- layer["registry"],
2125
- registry_roots,
2126
- managed_roots,
2127
- on_resolved=pack_scope.record,
2128
- ):
2129
- # Later packs override earlier ones.
2130
- skills_to_sync[name] = path
2131
- for skill in layer["manifest"].get("skills", []):
2132
- # Contract section 5 step 1: an entry a declared pack already
2133
- # provides is REDUNDANT, not an override. Dropping it here is what
2134
- # keeps `bmad-help` from pinning itself to a stale pack version
2135
- # while its 74 siblings follow the declared pack.
2136
- if pack_scope.is_redundant(skill):
2137
- print(
2138
- f"Skipping redundant skills[] entry {manifest_skill_name(skill)}: "
2139
- f"provided by a declared pack"
2140
- )
2141
- continue
2142
- name, path = resolve_skill_path(
2143
- skill,
2144
- cache_dir,
2145
- layer["base_dir"],
2146
- layer["registry"],
2147
- registry_roots,
2148
- )
2149
- if path:
2150
- # Contract section 5 step 2: a surviving explicit skills[] entry
2151
- # always overrides a pack member.
2152
- skills_to_sync[name] = path
2153
-
2154
- managed_roots.update(
2155
- Path(root)
2156
- for roots in registry_roots.values()
2157
- for root in roots
2158
- )
2159
-
2160
- # Re-validate the full destination topology, now including every
2161
- # pack-derived skill name, immediately before the first mutation.
2162
- active_cli_dirs = preflight_cli_dirs(
2163
- preflight_base,
2164
- [validate_skill_name(name) for name in skills_to_sync],
2165
- args.scope,
2166
- skill_sources=skills_to_sync,
2167
- )
2168
-
2169
- fanout_to_cli(
2170
- preflight_base,
2171
- skills_to_sync,
2172
- active_cli_dirs=active_cli_dirs,
2173
- scope=args.scope,
2174
- managed_roots=managed_roots,
2175
- reconcile=not args.no_reconcile,
2176
- reconcile_apply=not args.reconcile_dry_run,
2177
- )
2178
-
2179
- handle_retired_dirs(preflight_base, managed_roots, prune=args.prune_retired)
2180
-
2181
-
2182
- if __name__ == "__main__":
2183
- main()