agentbundle 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. agentbundle/__init__.py +14 -0
  2. agentbundle/__main__.py +5 -0
  3. agentbundle/_data/adapter.schema.json +270 -0
  4. agentbundle/_data/adapter.toml +584 -0
  5. agentbundle/_data/install-marker.py +1099 -0
  6. agentbundle/_data/pack.schema.json +152 -0
  7. agentbundle/_data/plugin-manifest.derived.schema.json +33 -0
  8. agentbundle/_data/plugin-manifest.schema.json +18 -0
  9. agentbundle/build/__init__.py +206 -0
  10. agentbundle/build/__main__.py +8 -0
  11. agentbundle/build/adapter_root_bins.py +336 -0
  12. agentbundle/build/adapters/__init__.py +46 -0
  13. agentbundle/build/adapters/claude_code.py +142 -0
  14. agentbundle/build/adapters/codex.py +227 -0
  15. agentbundle/build/adapters/copilot.py +149 -0
  16. agentbundle/build/adapters/kiro.py +608 -0
  17. agentbundle/build/adapters/kiro_cli.py +53 -0
  18. agentbundle/build/adapters/kiro_ide.py +275 -0
  19. agentbundle/build/contract.py +20 -0
  20. agentbundle/build/lint_packs.py +555 -0
  21. agentbundle/build/main.py +596 -0
  22. agentbundle/build/phase_order.py +40 -0
  23. agentbundle/build/projections/__init__.py +13 -0
  24. agentbundle/build/projections/codex_agent_toml.py +232 -0
  25. agentbundle/build/projections/copilot_agent_md.py +206 -0
  26. agentbundle/build/projections/copilot_hooks_json.py +142 -0
  27. agentbundle/build/projections/direct_directory.py +41 -0
  28. agentbundle/build/projections/hook_id.py +27 -0
  29. agentbundle/build/projections/kiro_ide_hook.py +256 -0
  30. agentbundle/build/projections/merge_into_agent_json.py +264 -0
  31. agentbundle/build/projections/merge_json.py +58 -0
  32. agentbundle/build/projections/user_merge_json.py +324 -0
  33. agentbundle/build/scope_rails.py +728 -0
  34. agentbundle/build/self_host.py +1486 -0
  35. agentbundle/build/shared_libs.py +309 -0
  36. agentbundle/build/target_resolver.py +85 -0
  37. agentbundle/build/tests/__init__.py +0 -0
  38. agentbundle/build/tests/test_adapter_claude_code.py +275 -0
  39. agentbundle/build/tests/test_adapter_codex.py +699 -0
  40. agentbundle/build/tests/test_adapter_copilot.py +91 -0
  41. agentbundle/build/tests/test_adapter_kiro.py +449 -0
  42. agentbundle/build/tests/test_adapter_kiro_alias.py +105 -0
  43. agentbundle/build/tests/test_adapter_kiro_cli.py +102 -0
  44. agentbundle/build/tests/test_adapter_kiro_ide.py +173 -0
  45. agentbundle/build/tests/test_adapter_root_bins_projection.py +429 -0
  46. agentbundle/build/tests/test_build_ships_seeds.py +78 -0
  47. agentbundle/build/tests/test_contract.py +582 -0
  48. agentbundle/build/tests/test_contract_scope.py +224 -0
  49. agentbundle/build/tests/test_contract_v07.py +191 -0
  50. agentbundle/build/tests/test_contract_v08.py +230 -0
  51. agentbundle/build/tests/test_direct_directory_cleanup.py +65 -0
  52. agentbundle/build/tests/test_end_to_end_build.py +227 -0
  53. agentbundle/build/tests/test_lint_agents_md_legacy_block.py +135 -0
  54. agentbundle/build/tests/test_lint_agents_md_risk_block.py +116 -0
  55. agentbundle/build/tests/test_lint_packs.py +703 -0
  56. agentbundle/build/tests/test_load_pack_hook_wiring_safely.py +176 -0
  57. agentbundle/build/tests/test_pack_schema.py +265 -0
  58. agentbundle/build/tests/test_pack_schema_allowed_adapters.py +258 -0
  59. agentbundle/build/tests/test_pack_schema_install.py +305 -0
  60. agentbundle/build/tests/test_pipeline.py +272 -0
  61. agentbundle/build/tests/test_plugin_manifest_schema.py +327 -0
  62. agentbundle/build/tests/test_projections_merge_json.py +148 -0
  63. agentbundle/build/tests/test_scope_rails.py +398 -0
  64. agentbundle/build/tests/test_security.py +97 -0
  65. agentbundle/build/tests/test_self_host_check.py +2100 -0
  66. agentbundle/build/tests/test_shared_libs_projection.py +415 -0
  67. agentbundle/build/tests/test_shipped_packs_v07_declarations.py +100 -0
  68. agentbundle/build/tests/test_shipped_packs_v08_declarations.py +80 -0
  69. agentbundle/build/tests/test_validate.py +250 -0
  70. agentbundle/build/validate.py +141 -0
  71. agentbundle/catalogue.py +164 -0
  72. agentbundle/cli.py +486 -0
  73. agentbundle/commands/__init__.py +5 -0
  74. agentbundle/commands/_common.py +174 -0
  75. agentbundle/commands/_drop_warning.py +329 -0
  76. agentbundle/commands/adapt.py +343 -0
  77. agentbundle/commands/config.py +125 -0
  78. agentbundle/commands/diff.py +211 -0
  79. agentbundle/commands/init_state.py +279 -0
  80. agentbundle/commands/install.py +3026 -0
  81. agentbundle/commands/list_packs.py +170 -0
  82. agentbundle/commands/list_targets.py +23 -0
  83. agentbundle/commands/reconcile.py +161 -0
  84. agentbundle/commands/render.py +165 -0
  85. agentbundle/commands/scaffold.py +69 -0
  86. agentbundle/commands/uninstall.py +294 -0
  87. agentbundle/commands/upgrade.py +699 -0
  88. agentbundle/commands/validate.py +688 -0
  89. agentbundle/config.py +747 -0
  90. agentbundle/render.py +123 -0
  91. agentbundle/safety.py +633 -0
  92. agentbundle/scope.py +319 -0
  93. agentbundle/user_config.py +284 -0
  94. agentbundle/version.py +49 -0
  95. agentbundle-0.2.0.dist-info/METADATA +37 -0
  96. agentbundle-0.2.0.dist-info/RECORD +99 -0
  97. agentbundle-0.2.0.dist-info/WHEEL +5 -0
  98. agentbundle-0.2.0.dist-info/entry_points.txt +2 -0
  99. agentbundle-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,555 @@
1
+ """Pack-source lint — refuse packs whose content would break either
2
+ Windows portability or per-target metadata caps at projection time.
3
+
4
+ Three checks, applied to every pack under a `--packs-dir`:
5
+
6
+ 1. **No symlinks** — `Path.is_symlink()` against the entry. Windows
7
+ symlink creation requires Developer Mode or admin privileges, and
8
+ packs distributed via git/zip/zipapp lose symlink fidelity along
9
+ the way.
10
+ 2. **No Windows-poisonous names** — every path is run through
11
+ `safety.assert_portable_name`, which rejects reserved device names
12
+ (CON/PRN/AUX/NUL/COM1-9/LPT1-9), trailing dots or spaces, and the
13
+ `<>:"|?*` character set.
14
+ 3. **Per-target metadata caps** — for each pack's `.apm/skills/` and
15
+ `.apm/agents/` source, refuse skill/agent names that don't match
16
+ the strictest `name-pattern` across declared targets, names that
17
+ exceed the strictest `name-max-length`, and descriptions that
18
+ exceed the strictest `description-max-length`. Multi-line YAML
19
+ descriptions (`>`, `|`, continuation lines) are refused outright
20
+ rather than mis-parsed. Constraints come from
21
+ `docs/contracts/target-vocab.toml` — see
22
+ `docs/specs/lint-packs-target-vocab/spec.md`.
23
+
24
+ The lint is Python-only so it runs on every CI platform without
25
+ shelling out, and it is wired into `make build` / `make build-self` /
26
+ `make build-check` as a hard prerequisite.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import argparse
32
+ import re
33
+ import sys
34
+ import tomllib
35
+ from typing import NamedTuple, Pattern
36
+ from pathlib import Path
37
+
38
+ from agentbundle.safety import PathJailError, assert_portable_name
39
+
40
+ # Subtrees in a pack that ship to adopters. `seeds/` is the
41
+ # adopter-facing surface; `.apm/` is the primitives the APM adapter
42
+ # unpacks. Both must be portable. `pack.toml` and `.claude-plugin/`
43
+ # live outside the walk because their schemas already constrain
44
+ # their content.
45
+ _PACK_SUBTREES = ("seeds", ".apm")
46
+
47
+ # Path to the sibling vocab file, relative to a repo root. The loader
48
+ # walks up from a caller-supplied start until an ancestor contains
49
+ # this relative path.
50
+ _VOCAB_RELPATH = Path("docs/contracts/target-vocab.toml")
51
+
52
+ # Sentinel returned by `_extract_frontmatter_fields` when a key's
53
+ # value position is `>`, `|`, or empty (signaling a folded / nested
54
+ # block). The metadata checks translate this into an AC12 finding
55
+ # rather than try to parse the continuation.
56
+ _MULTILINE = object()
57
+
58
+
59
+ class Constraints(NamedTuple):
60
+ """Strictest-cap snapshot of `docs/contracts/target-vocab.toml`.
61
+
62
+ `binding_targets` keys (`"description_max"`, `"name_max"`,
63
+ `"name_pattern"`) carry the ASCII-sorted list of targets enforcing
64
+ the binding value. Findings render the list as
65
+ `binding target: <comma-joined>`.
66
+ """
67
+
68
+ description_max: int
69
+ name_pattern: Pattern[str]
70
+ name_max: int
71
+ binding_targets: dict[str, list[str]]
72
+
73
+
74
+ def _walk_up_for_vocab(start: Path) -> Path | None:
75
+ cursor = start.resolve()
76
+ while True:
77
+ possible = cursor / _VOCAB_RELPATH
78
+ if possible.is_file():
79
+ return possible
80
+ if cursor.parent == cursor:
81
+ return None
82
+ cursor = cursor.parent
83
+
84
+
85
+ def _load_target_vocab(start: Path) -> tuple[dict | None, str | None]:
86
+ """Walk up from `start` looking for `docs/contracts/target-vocab.toml`;
87
+ fall back to walking up from this module's own ancestor chain when
88
+ the explicit walk fails. This keeps the gate working when an
89
+ adopter points `--packs-dir` at a tmp tree outside the repo while
90
+ still picking up the in-tree vocab. The legacy pre-PR
91
+ `LintPackTests` rely on this fallback. Returns `(vocab_dict, None)`
92
+ on success, `(None, err)` when **both** walks fail or the file is
93
+ malformed."""
94
+ candidate = _walk_up_for_vocab(start)
95
+ if candidate is None:
96
+ candidate = _walk_up_for_vocab(Path(__file__).parent)
97
+ if candidate is None:
98
+ return None, (
99
+ "lint-packs: target-vocab.toml not found (walked up from "
100
+ f"{start} and from this module's ancestor chain; expected "
101
+ f"{_VOCAB_RELPATH.as_posix()})"
102
+ )
103
+ try:
104
+ raw = tomllib.loads(candidate.read_text(encoding="utf-8"))
105
+ except tomllib.TOMLDecodeError as exc:
106
+ return None, f"lint-packs: failed to parse {candidate}: {exc}"
107
+ targets = raw.get("target")
108
+ if not isinstance(targets, dict) or not targets:
109
+ return None, (
110
+ f"lint-packs: {candidate} has no [target.<name>] tables — "
111
+ f"the metadata gate has no constraints to apply"
112
+ )
113
+ return raw, None
114
+
115
+
116
+ def _strictest_constraints(vocab: dict) -> tuple[Constraints | None, str | None]:
117
+ """Collapse per-target caps into the strictest binding. Returns
118
+ `(constraints, None)` on success, `(None, err)` if targets disagree
119
+ on `name-pattern` (which would require regex intersection — not
120
+ well-defined; the loader refuses rather than picking one)."""
121
+ targets: dict[str, dict] = vocab["target"]
122
+
123
+ # `name-pattern` must be byte-equal across every declared target —
124
+ # regex intersection is not a defined operation, so disagreement
125
+ # is refused (AC1 + AC11).
126
+ pattern_per_target: dict[str, str] = {}
127
+ for name, table in targets.items():
128
+ if not isinstance(table, dict):
129
+ continue
130
+ pattern = table.get("name-pattern")
131
+ if not isinstance(pattern, str):
132
+ return None, (
133
+ f"lint-packs: target {name!r} is missing `name-pattern` in "
134
+ f"target-vocab.toml"
135
+ )
136
+ pattern_per_target[name] = pattern
137
+ distinct_patterns = set(pattern_per_target.values())
138
+ if len(distinct_patterns) != 1:
139
+ # AC11 — name-pattern disagreement is refused. Report which
140
+ # targets contributed which pattern so the file author can fix
141
+ # the divergence without re-reading the loader source.
142
+ groups: dict[str, list[str]] = {}
143
+ for tgt, pat in sorted(pattern_per_target.items()):
144
+ groups.setdefault(pat, []).append(tgt)
145
+ rendered = "; ".join(
146
+ f"{pat!r}: {', '.join(group)}" for pat, group in sorted(groups.items())
147
+ )
148
+ return None, (
149
+ f"lint-packs: target-vocab.toml declares inconsistent "
150
+ f"name-pattern values across targets ({rendered}) — every "
151
+ f"declared target must share the same pattern (regex "
152
+ f"intersection is not well-defined)"
153
+ )
154
+ compiled_pattern = re.compile(next(iter(distinct_patterns)))
155
+
156
+ # Numeric caps — collect each target's value, find the minimum
157
+ # (strictest binding), record which targets hit that minimum.
158
+ desc_caps: dict[str, int] = {}
159
+ name_caps: dict[str, int] = {}
160
+ for name, table in targets.items():
161
+ if not isinstance(table, dict):
162
+ continue
163
+ if isinstance(table.get("description-max-length"), int):
164
+ desc_caps[name] = table["description-max-length"]
165
+ if isinstance(table.get("name-max-length"), int):
166
+ name_caps[name] = table["name-max-length"]
167
+
168
+ if not desc_caps:
169
+ return None, (
170
+ "lint-packs: target-vocab.toml declares no "
171
+ "`description-max-length` on any target — the metadata gate "
172
+ "needs at least one target with a description cap"
173
+ )
174
+ if not name_caps:
175
+ return None, (
176
+ "lint-packs: target-vocab.toml declares no `name-max-length` "
177
+ "on any target — the metadata gate needs at least one target "
178
+ "with a name-length cap"
179
+ )
180
+
181
+ desc_min = min(desc_caps.values())
182
+ name_min = min(name_caps.values())
183
+ binding_targets = {
184
+ "description_max": sorted(t for t, v in desc_caps.items() if v == desc_min),
185
+ "name_max": sorted(t for t, v in name_caps.items() if v == name_min),
186
+ "name_pattern": sorted(targets.keys()),
187
+ }
188
+ return (
189
+ Constraints(
190
+ description_max=desc_min,
191
+ name_pattern=compiled_pattern,
192
+ name_max=name_min,
193
+ binding_targets=binding_targets,
194
+ ),
195
+ None,
196
+ )
197
+
198
+
199
+ def _render_binding(constraints: Constraints, field: str) -> str:
200
+ return ", ".join(constraints.binding_targets[field])
201
+
202
+
203
+ def _extract_frontmatter_fields(
204
+ text: str, keys: set[str]
205
+ ) -> dict[str, object]:
206
+ """Return `{key: value}` for each requested key found in the
207
+ `--- ... ---` frontmatter at the head of `text`.
208
+
209
+ Values are either strings (single-line scalars, with balanced
210
+ surrounding quotes stripped) or the `_MULTILINE` sentinel for keys
211
+ whose value position is `>`, `|`, or empty (signalling a folded /
212
+ nested block). Keys not present in the frontmatter are absent from
213
+ the returned dict. Files without a `---` fence return `{}`.
214
+ """
215
+ # Strip a leading UTF-8 BOM so a SKILL.md saved by a Windows editor
216
+ # still has its frontmatter recognised — without this, `lines[0]`
217
+ # would carry the BOM and the parser would silently return `{}`,
218
+ # letting an over-cap description slip through.
219
+ text = text.lstrip("")
220
+ lines = text.splitlines()
221
+ if not lines or lines[0].strip() != "---":
222
+ return {}
223
+ end = None
224
+ for i in range(1, len(lines)):
225
+ if lines[i].strip() == "---":
226
+ end = i
227
+ break
228
+ if end is None:
229
+ return {}
230
+ found: dict[str, object] = {}
231
+ i = 1
232
+ while i < end:
233
+ raw = lines[i]
234
+ if not raw.strip():
235
+ i += 1
236
+ continue
237
+ # Only care about top-level keys; indented continuation lines
238
+ # are handled below per-key.
239
+ match = re.match(r"^([a-zA-Z][a-zA-Z0-9_-]*):\s*(.*)$", raw)
240
+ if not match:
241
+ i += 1
242
+ continue
243
+ key, value = match.group(1), match.group(2).strip()
244
+ if key in keys:
245
+ if value in ("", ">", "|") or value.startswith((">", "|")):
246
+ # Folded / continuation block. Peek the next non-blank
247
+ # line: if it's indented, this is a multi-line value
248
+ # we refuse to parse; if it's a top-level key or the
249
+ # closing fence, treat the value as an empty scalar
250
+ # (also refused — empty descriptions are caught
251
+ # downstream by description-presence checks).
252
+ j = i + 1
253
+ continuation = False
254
+ while j < end:
255
+ nxt = lines[j]
256
+ if not nxt.strip():
257
+ j += 1
258
+ continue
259
+ indent = len(nxt) - len(nxt.lstrip())
260
+ if indent > 0:
261
+ continuation = True
262
+ break
263
+ if continuation or value in (">", "|") or value.startswith((">", "|")):
264
+ found[key] = _MULTILINE
265
+ else:
266
+ found[key] = ""
267
+ else:
268
+ if (
269
+ len(value) >= 2
270
+ and value[0] == value[-1]
271
+ and value[0] in ('"', "'")
272
+ ):
273
+ value = value[1:-1]
274
+ found[key] = value
275
+ i += 1
276
+ return found
277
+
278
+
279
+ def _check_skill_metadata(pack_dir: Path, constraints: Constraints) -> list[str]:
280
+ findings: list[str] = []
281
+ skills_dir = pack_dir / ".apm" / "skills"
282
+ if not skills_dir.is_dir():
283
+ return findings
284
+ for entry in sorted(skills_dir.iterdir()):
285
+ if not entry.is_dir():
286
+ continue
287
+ dir_name = entry.name
288
+ skill_md = entry / "SKILL.md"
289
+ relpath = (
290
+ skill_md.relative_to(pack_dir).as_posix()
291
+ if skill_md.is_file()
292
+ else entry.relative_to(pack_dir).as_posix() + "/"
293
+ )
294
+ # On-disk name checks — pattern + length for the dir name.
295
+ primary = _pattern_finding(
296
+ pack_dir.name, "skill", dir_name, dir_name, constraints, relpath
297
+ )
298
+ if primary is not None:
299
+ findings.append(primary)
300
+ length = _length_finding(
301
+ pack_dir.name, "skill", dir_name, constraints, relpath
302
+ )
303
+ if length is not None:
304
+ findings.append(length)
305
+ if not skill_md.is_file():
306
+ continue
307
+ text = skill_md.read_text(encoding="utf-8")
308
+ findings.extend(
309
+ _description_findings(
310
+ pack_dir.name, "skill", dir_name, text, constraints, relpath
311
+ )
312
+ )
313
+ # Frontmatter `name:` — multi-line refused (AC12); when
314
+ # present and a single-line scalar that differs from the
315
+ # dir, run the pattern check (AC2).
316
+ fields = _extract_frontmatter_fields(text, {"name"})
317
+ fm_name = fields.get("name")
318
+ if fm_name is _MULTILINE:
319
+ findings.append(
320
+ f"{pack_dir.name}: skill/{dir_name}: name must be "
321
+ f"a single-line value: {relpath}"
322
+ )
323
+ elif isinstance(fm_name, str) and fm_name and fm_name != dir_name:
324
+ fm_finding = _pattern_finding(
325
+ pack_dir.name, "skill", dir_name, fm_name, constraints, relpath
326
+ )
327
+ if fm_finding is not None:
328
+ findings.append(fm_finding)
329
+ return findings
330
+
331
+
332
+ def _check_agent_metadata(pack_dir: Path, constraints: Constraints) -> list[str]:
333
+ findings: list[str] = []
334
+ agents_dir = pack_dir / ".apm" / "agents"
335
+ if not agents_dir.is_dir():
336
+ return findings
337
+ for entry in sorted(agents_dir.iterdir()):
338
+ if not entry.is_file() or entry.suffix != ".md":
339
+ continue
340
+ stem = entry.stem
341
+ relpath = entry.relative_to(pack_dir).as_posix()
342
+ primary = _pattern_finding(
343
+ pack_dir.name, "agent", stem, stem, constraints, relpath
344
+ )
345
+ if primary is not None:
346
+ findings.append(primary)
347
+ length = _length_finding(
348
+ pack_dir.name, "agent", stem, constraints, relpath
349
+ )
350
+ if length is not None:
351
+ findings.append(length)
352
+ text = entry.read_text(encoding="utf-8")
353
+ findings.extend(
354
+ _description_findings(
355
+ pack_dir.name, "agent", stem, text, constraints, relpath
356
+ )
357
+ )
358
+ fields = _extract_frontmatter_fields(text, {"name"})
359
+ fm_name = fields.get("name")
360
+ if fm_name is _MULTILINE:
361
+ findings.append(
362
+ f"{pack_dir.name}: agent/{stem}: name must be "
363
+ f"a single-line value: {relpath}"
364
+ )
365
+ elif isinstance(fm_name, str) and fm_name and fm_name != stem:
366
+ fm_finding = _pattern_finding(
367
+ pack_dir.name, "agent", stem, fm_name, constraints, relpath
368
+ )
369
+ if fm_finding is not None:
370
+ findings.append(fm_finding)
371
+ return findings
372
+
373
+
374
+ def _pattern_finding(
375
+ pack_name: str,
376
+ primitive: str,
377
+ display_name: str,
378
+ candidate: str,
379
+ constraints: Constraints,
380
+ relpath: str,
381
+ ) -> str | None:
382
+ """Pattern-only check for one candidate name. Used by both the
383
+ on-disk-name check (where `candidate == display_name`) and the
384
+ frontmatter-`name:` mismatch check (where `candidate` is the
385
+ frontmatter value). The finding embeds the candidate verbatim
386
+ so the two cases are distinguishable by inspection."""
387
+ if constraints.name_pattern.match(candidate):
388
+ return None
389
+ return (
390
+ f"{pack_name}: {primitive}/{display_name}: "
391
+ f"name does not match {constraints.name_pattern.pattern} "
392
+ f"(got {candidate!r}; "
393
+ f"binding target: {_render_binding(constraints, 'name_pattern')}): "
394
+ f"{relpath}"
395
+ )
396
+
397
+
398
+ def _length_finding(
399
+ pack_name: str,
400
+ primitive: str,
401
+ display_name: str,
402
+ constraints: Constraints,
403
+ relpath: str,
404
+ ) -> str | None:
405
+ """Length check for the on-disk name only. The display_name slot
406
+ IS the candidate; no separate frontmatter-name length finding —
407
+ per spec AC3, projection risk for over-long frontmatter `name:`
408
+ is not separately documented and the dir/stem length covers the
409
+ operational case."""
410
+ if len(display_name) <= constraints.name_max:
411
+ return None
412
+ return (
413
+ f"{pack_name}: {primitive}/{display_name}: "
414
+ f"name length exceeds {constraints.name_max} "
415
+ f"(got {len(display_name)}; "
416
+ f"binding target: {_render_binding(constraints, 'name_max')}): "
417
+ f"{relpath}"
418
+ )
419
+
420
+
421
+ def _description_findings(
422
+ pack_name: str,
423
+ primitive: str,
424
+ display_name: str,
425
+ text: str,
426
+ constraints: Constraints,
427
+ relpath: str,
428
+ ) -> list[str]:
429
+ out: list[str] = []
430
+ fields = _extract_frontmatter_fields(text, {"description"})
431
+ description = fields.get("description")
432
+ if description is _MULTILINE:
433
+ out.append(
434
+ f"{pack_name}: {primitive}/{display_name}: description "
435
+ f"must be a single-line value: {relpath}"
436
+ )
437
+ return out
438
+ if not isinstance(description, str) or not description:
439
+ return out
440
+ if len(description) > constraints.description_max:
441
+ out.append(
442
+ f"{pack_name}: {primitive}/{display_name}: description length "
443
+ f"exceeds {constraints.description_max} (got {len(description)}; "
444
+ f"binding target: {_render_binding(constraints, 'description_max')}): "
445
+ f"{relpath}"
446
+ )
447
+ return out
448
+
449
+
450
+ def lint_pack(pack_dir: Path, constraints: Constraints | None = None) -> list[str]:
451
+ """Return a list of human-readable violation strings for one pack.
452
+
453
+ Empty list ⇒ clean. Each string is suitable for stderr emission;
454
+ callers decide how to format / exit.
455
+
456
+ When `constraints` is supplied, the per-target metadata gate runs
457
+ after the portability sweep and the combined findings are sorted
458
+ by trailing relpath (the AC10 invariant). When omitted, behaviour
459
+ matches the pre-vocab gate exactly.
460
+ """
461
+ findings: list[str] = []
462
+ for subtree_name in _PACK_SUBTREES:
463
+ subtree = pack_dir / subtree_name
464
+ if not subtree.exists():
465
+ continue
466
+ # Walk via `rglob("*")` so directory entries are also checked;
467
+ # a reserved-name *directory* (e.g. `seeds/NUL/`) is just as
468
+ # poisonous as a reserved-name file.
469
+ for entry in sorted(subtree.rglob("*")):
470
+ relpath = entry.relative_to(pack_dir).as_posix()
471
+ if entry.is_symlink():
472
+ findings.append(
473
+ f"{pack_dir.name}: symlink not portable to Windows: {relpath}"
474
+ )
475
+ # Don't descend into symlinks — they may target outside
476
+ # the pack and trigger spurious findings. The symlink
477
+ # itself is already the violation.
478
+ continue
479
+ try:
480
+ assert_portable_name(relpath)
481
+ except PathJailError as exc:
482
+ findings.append(f"{pack_dir.name}: {exc}")
483
+ if constraints is not None:
484
+ findings.extend(_check_skill_metadata(pack_dir, constraints))
485
+ findings.extend(_check_agent_metadata(pack_dir, constraints))
486
+ # Sort unconditionally so the trailing-relpath invariant holds in
487
+ # both call modes — a portability-only caller with violations
488
+ # spanning both subtrees gets the same deterministic ordering as
489
+ # the gated path. Per-subtree `rglob` already returns entries
490
+ # sorted, so for single-subtree fixtures the sort is a no-op.
491
+ findings.sort(key=lambda f: f.rsplit(": ", 1)[-1])
492
+ return findings
493
+
494
+
495
+ def lint_all_packs(
496
+ packs_dir: Path,
497
+ constraints: Constraints | None = None,
498
+ ) -> dict[str, list[str]]:
499
+ """Walk every immediate subdirectory of `packs_dir` that contains a
500
+ `pack.toml`, return `{pack_name: [findings...]}`.
501
+
502
+ Missing `packs_dir` returns an empty dict — caller decides whether
503
+ that's an error in their context.
504
+ """
505
+ result: dict[str, list[str]] = {}
506
+ if not packs_dir.exists():
507
+ return result
508
+ for entry in sorted(packs_dir.iterdir()):
509
+ if not entry.is_dir():
510
+ continue
511
+ if not (entry / "pack.toml").exists():
512
+ continue
513
+ result[entry.name] = lint_pack(entry, constraints=constraints)
514
+ return result
515
+
516
+
517
+ def cmd_lint_packs(args: argparse.Namespace) -> int:
518
+ """argparse entrypoint. Exit code:
519
+ 0 — every pack clean
520
+ 1 — at least one finding, or vocab load failure
521
+ """
522
+ packs_dir = Path(args.packs_dir).resolve()
523
+ if not packs_dir.exists():
524
+ print(f"lint-packs: packs-dir not found: {packs_dir}", file=sys.stderr)
525
+ return 1
526
+ vocab, err = _load_target_vocab(packs_dir)
527
+ if err is not None:
528
+ print(err, file=sys.stderr)
529
+ print(
530
+ "lint-packs: configuration error — no packs were checked",
531
+ file=sys.stderr,
532
+ )
533
+ return 1
534
+ constraints, err = _strictest_constraints(vocab)
535
+ if err is not None:
536
+ print(err, file=sys.stderr)
537
+ print(
538
+ "lint-packs: configuration error — no packs were checked",
539
+ file=sys.stderr,
540
+ )
541
+ return 1
542
+ results = lint_all_packs(packs_dir, constraints=constraints)
543
+ total = 0
544
+ for _pack_name, findings in results.items():
545
+ for finding in findings:
546
+ print(finding, file=sys.stderr)
547
+ total += 1
548
+ if total:
549
+ print(
550
+ f"lint-packs: {total} violation(s) across "
551
+ f"{sum(1 for f in results.values() if f)} pack(s)",
552
+ file=sys.stderr,
553
+ )
554
+ return 1
555
+ return 0