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.
- agentbundle/__init__.py +14 -0
- agentbundle/__main__.py +5 -0
- agentbundle/_data/adapter.schema.json +270 -0
- agentbundle/_data/adapter.toml +584 -0
- agentbundle/_data/install-marker.py +1099 -0
- agentbundle/_data/pack.schema.json +152 -0
- agentbundle/_data/plugin-manifest.derived.schema.json +33 -0
- agentbundle/_data/plugin-manifest.schema.json +18 -0
- agentbundle/build/__init__.py +206 -0
- agentbundle/build/__main__.py +8 -0
- agentbundle/build/adapter_root_bins.py +336 -0
- agentbundle/build/adapters/__init__.py +46 -0
- agentbundle/build/adapters/claude_code.py +142 -0
- agentbundle/build/adapters/codex.py +227 -0
- agentbundle/build/adapters/copilot.py +149 -0
- agentbundle/build/adapters/kiro.py +608 -0
- agentbundle/build/adapters/kiro_cli.py +53 -0
- agentbundle/build/adapters/kiro_ide.py +275 -0
- agentbundle/build/contract.py +20 -0
- agentbundle/build/lint_packs.py +555 -0
- agentbundle/build/main.py +596 -0
- agentbundle/build/phase_order.py +40 -0
- agentbundle/build/projections/__init__.py +13 -0
- agentbundle/build/projections/codex_agent_toml.py +232 -0
- agentbundle/build/projections/copilot_agent_md.py +206 -0
- agentbundle/build/projections/copilot_hooks_json.py +142 -0
- agentbundle/build/projections/direct_directory.py +41 -0
- agentbundle/build/projections/hook_id.py +27 -0
- agentbundle/build/projections/kiro_ide_hook.py +256 -0
- agentbundle/build/projections/merge_into_agent_json.py +264 -0
- agentbundle/build/projections/merge_json.py +58 -0
- agentbundle/build/projections/user_merge_json.py +324 -0
- agentbundle/build/scope_rails.py +728 -0
- agentbundle/build/self_host.py +1486 -0
- agentbundle/build/shared_libs.py +309 -0
- agentbundle/build/target_resolver.py +85 -0
- agentbundle/build/tests/__init__.py +0 -0
- agentbundle/build/tests/test_adapter_claude_code.py +275 -0
- agentbundle/build/tests/test_adapter_codex.py +699 -0
- agentbundle/build/tests/test_adapter_copilot.py +91 -0
- agentbundle/build/tests/test_adapter_kiro.py +449 -0
- agentbundle/build/tests/test_adapter_kiro_alias.py +105 -0
- agentbundle/build/tests/test_adapter_kiro_cli.py +102 -0
- agentbundle/build/tests/test_adapter_kiro_ide.py +173 -0
- agentbundle/build/tests/test_adapter_root_bins_projection.py +429 -0
- agentbundle/build/tests/test_build_ships_seeds.py +78 -0
- agentbundle/build/tests/test_contract.py +582 -0
- agentbundle/build/tests/test_contract_scope.py +224 -0
- agentbundle/build/tests/test_contract_v07.py +191 -0
- agentbundle/build/tests/test_contract_v08.py +230 -0
- agentbundle/build/tests/test_direct_directory_cleanup.py +65 -0
- agentbundle/build/tests/test_end_to_end_build.py +227 -0
- agentbundle/build/tests/test_lint_agents_md_legacy_block.py +135 -0
- agentbundle/build/tests/test_lint_agents_md_risk_block.py +116 -0
- agentbundle/build/tests/test_lint_packs.py +703 -0
- agentbundle/build/tests/test_load_pack_hook_wiring_safely.py +176 -0
- agentbundle/build/tests/test_pack_schema.py +265 -0
- agentbundle/build/tests/test_pack_schema_allowed_adapters.py +258 -0
- agentbundle/build/tests/test_pack_schema_install.py +305 -0
- agentbundle/build/tests/test_pipeline.py +272 -0
- agentbundle/build/tests/test_plugin_manifest_schema.py +327 -0
- agentbundle/build/tests/test_projections_merge_json.py +148 -0
- agentbundle/build/tests/test_scope_rails.py +398 -0
- agentbundle/build/tests/test_security.py +97 -0
- agentbundle/build/tests/test_self_host_check.py +2100 -0
- agentbundle/build/tests/test_shared_libs_projection.py +415 -0
- agentbundle/build/tests/test_shipped_packs_v07_declarations.py +100 -0
- agentbundle/build/tests/test_shipped_packs_v08_declarations.py +80 -0
- agentbundle/build/tests/test_validate.py +250 -0
- agentbundle/build/validate.py +141 -0
- agentbundle/catalogue.py +164 -0
- agentbundle/cli.py +486 -0
- agentbundle/commands/__init__.py +5 -0
- agentbundle/commands/_common.py +174 -0
- agentbundle/commands/_drop_warning.py +329 -0
- agentbundle/commands/adapt.py +343 -0
- agentbundle/commands/config.py +125 -0
- agentbundle/commands/diff.py +211 -0
- agentbundle/commands/init_state.py +279 -0
- agentbundle/commands/install.py +3026 -0
- agentbundle/commands/list_packs.py +170 -0
- agentbundle/commands/list_targets.py +23 -0
- agentbundle/commands/reconcile.py +161 -0
- agentbundle/commands/render.py +165 -0
- agentbundle/commands/scaffold.py +69 -0
- agentbundle/commands/uninstall.py +294 -0
- agentbundle/commands/upgrade.py +699 -0
- agentbundle/commands/validate.py +688 -0
- agentbundle/config.py +747 -0
- agentbundle/render.py +123 -0
- agentbundle/safety.py +633 -0
- agentbundle/scope.py +319 -0
- agentbundle/user_config.py +284 -0
- agentbundle/version.py +49 -0
- agentbundle-0.2.0.dist-info/METADATA +37 -0
- agentbundle-0.2.0.dist-info/RECORD +99 -0
- agentbundle-0.2.0.dist-info/WHEEL +5 -0
- agentbundle-0.2.0.dist-info/entry_points.txt +2 -0
- 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
|