bmad-module-skill-forge 1.7.0 → 1.9.0

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 (53) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/docs/_data/pinned.yaml +1 -1
  3. package/docs/bmad-synergy.md +16 -0
  4. package/docs/deepwiki.md +89 -0
  5. package/docs/verifying-a-skill.md +8 -2
  6. package/docs/workflows.md +17 -11
  7. package/package.json +2 -2
  8. package/src/shared/_known-workarounds.yaml +155 -0
  9. package/src/shared/references/pipeline-contracts.md +11 -2
  10. package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +6 -1
  11. package/src/shared/scripts/schemas/skill-brief.v1.json +6 -1
  12. package/src/shared/scripts/skf-detect-docs.py +412 -0
  13. package/src/shared/scripts/skf-emit-brief-result-envelope.py +12 -1
  14. package/src/shared/scripts/skf-forge-tier-rw.py +73 -0
  15. package/src/shared/scripts/skf-preapply.py +192 -0
  16. package/src/shared/scripts/skf-shape-detect.py +563 -0
  17. package/src/shared/scripts/skf-validate-frontmatter.py +56 -5
  18. package/src/shared/scripts/skf-validate-pins.py +368 -0
  19. package/src/skf-analyze-source/SKILL.md +26 -20
  20. package/src/skf-analyze-source/assets/skill-brief-schema.md +2 -1
  21. package/src/skf-analyze-source/references/identify-units.md +41 -4
  22. package/src/skf-analyze-source/references/init.md +36 -0
  23. package/src/skf-analyze-source/references/scan-project.md +5 -2
  24. package/src/skf-analyze-source/references/step-auto-scope.md +525 -0
  25. package/src/skf-analyze-source/references/step-shape-detect.md +79 -0
  26. package/src/skf-analyze-source/references/unit-detection-heuristics.md +16 -0
  27. package/src/skf-audit-skill/SKILL.md +1 -0
  28. package/src/skf-audit-skill/assets/drift-report-template.md +6 -0
  29. package/src/skf-audit-skill/references/report.md +4 -0
  30. package/src/skf-audit-skill/references/severity-classify.md +1 -1
  31. package/src/skf-audit-skill/references/step-doc-drift.md +147 -0
  32. package/src/skf-brief-skill/SKILL.md +7 -3
  33. package/src/skf-brief-skill/references/gather-intent.md +14 -0
  34. package/src/skf-brief-skill/references/step-auto-brief.md +171 -0
  35. package/src/skf-brief-skill/references/step-auto-validate.md +209 -0
  36. package/src/skf-create-skill/SKILL.md +3 -0
  37. package/src/skf-create-skill/assets/skill-sections.md +2 -0
  38. package/src/skf-create-skill/references/compile.md +1 -1
  39. package/src/skf-create-skill/references/generate-artifacts.md +5 -10
  40. package/src/skf-create-skill/references/step-auto-shard.md +110 -0
  41. package/src/skf-create-skill/references/step-doc-rot.md +109 -0
  42. package/src/skf-create-skill/references/step-doc-sources.md +114 -0
  43. package/src/skf-create-stack-skill/references/generate-output.md +5 -5
  44. package/src/skf-forger/SKILL.md +8 -2
  45. package/src/skf-test-skill/SKILL.md +8 -7
  46. package/src/skf-test-skill/customize.toml +2 -1
  47. package/src/skf-test-skill/references/external-validators.md +1 -1
  48. package/src/skf-test-skill/references/init.md +16 -1
  49. package/src/skf-test-skill/references/report.md +5 -1
  50. package/src/skf-test-skill/references/score.md +95 -6
  51. package/src/skf-test-skill/references/source-access-protocol.md +14 -0
  52. package/src/skf-test-skill/references/step-hard-gate.md +73 -0
  53. package/src/skf-test-skill/templates/test-report-template.md +1 -0
@@ -0,0 +1,563 @@
1
+ # /// script
2
+ # requires-python = ">=3.9"
3
+ # dependencies = []
4
+ # ///
5
+ """SKF Shape Detect — classify repos into known skill shapes from manifest files.
6
+
7
+ Single source of truth for shape-level classification consumed by
8
+ skf-analyze-source (AN auto-scope), skf-brief-skill (BS auto-brief),
9
+ and skf-test-skill (TS threshold selection). Moving shape heuristics
10
+ into a shared script eliminates duplicate classification logic across
11
+ three pipelines.
12
+
13
+ The five-shape heuristic ladder (apply in order, first match wins):
14
+
15
+ 1. language-reference — parser/grammar/language-toolchain project
16
+ Signals: parser-related deps (pest, antlr4, tree-sitter, lark ...)
17
+ 2. stack-compose — multi-ecosystem composite project
18
+ Signals: manifests from 2+ distinct ecosystems
19
+ 3. reference-app — application, CLI, or demo project
20
+ Signals: npm bin field, Rust [[bin]], framework deps
21
+ 4. library-API — library exposing a programmatic API
22
+ Signals: main/module/exports fields, [lib] target, export count
23
+ 5. unknown — no heuristic matched
24
+
25
+ CLI:
26
+ uv run python src/shared/scripts/skf-shape-detect.py \\
27
+ --repo-url <url> --manifests <path1,path2,...>
28
+
29
+ Input:
30
+ --repo-url repository URL (required; context only, no cloning)
31
+ --manifests comma-separated local file paths to manifest files (required)
32
+
33
+ Output (JSON on stdout):
34
+ shape library-API | reference-app | language-reference
35
+ | stack-compose | unknown
36
+ signals array of human-readable evidence strings
37
+ confidence float 0.0-1.0
38
+ export_count integer (total public-facing exports)
39
+ package_count integer (distinct packages detected)
40
+
41
+ Exit codes:
42
+ 0 shape classified (not unknown)
43
+ 1 unknown shape (no heuristic matched)
44
+ 2 error (invalid args, missing/unreadable files, parse failure)
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import argparse
50
+ import json
51
+ import sys
52
+ from pathlib import Path
53
+ from typing import Any
54
+
55
+ try:
56
+ import tomllib
57
+ except ImportError:
58
+ tomllib = None # type: ignore[assignment]
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Parser/grammar deps that signal language-reference shape
62
+ # ---------------------------------------------------------------------------
63
+
64
+ _PARSER_DEPS_NPM = frozenset({
65
+ "antlr4", "antlr4-runtime", "tree-sitter", "nearley",
66
+ "chevrotain", "pegjs", "peggy", "ohm-js", "jison",
67
+ "moo", "lezer", "@lezer/generator",
68
+ })
69
+ _PARSER_DEPS_PYTHON = frozenset({
70
+ "antlr4-tools", "antlr4-runtime", "lark", "lark-parser",
71
+ "ply", "tree-sitter", "textx", "parso", "pyparsing",
72
+ "sly", "tatsu",
73
+ })
74
+ _PARSER_DEPS_RUST = frozenset({
75
+ "pest", "pest_derive", "lalrpop", "lalrpop-util",
76
+ "tree-sitter", "nom", "chumsky", "winnow", "logos",
77
+ })
78
+
79
+ _ALL_PARSER_DEPS = _PARSER_DEPS_NPM | _PARSER_DEPS_PYTHON | _PARSER_DEPS_RUST
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Framework deps that signal reference-app shape
83
+ # ---------------------------------------------------------------------------
84
+
85
+ _FRAMEWORK_DEPS_NPM = frozenset({
86
+ "next", "nuxt", "express", "fastify", "koa", "hono",
87
+ "@nestjs/core", "gatsby", "electron",
88
+ })
89
+ _FRAMEWORK_DEPS_PYTHON = frozenset({
90
+ "django", "flask", "fastapi", "uvicorn", "starlette",
91
+ "tornado", "aiohttp", "sanic", "streamlit", "gradio",
92
+ })
93
+ _FRAMEWORK_DEPS_RUST = frozenset({
94
+ "actix-web", "axum", "rocket", "warp", "tide",
95
+ "tauri", "dioxus", "leptos", "yew",
96
+ })
97
+
98
+ _ALL_FRAMEWORK_DEPS = _FRAMEWORK_DEPS_NPM | _FRAMEWORK_DEPS_PYTHON | _FRAMEWORK_DEPS_RUST
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # Helpers
103
+ # ---------------------------------------------------------------------------
104
+
105
+ def _die(message: str, code: str = "INTERNAL_ERROR") -> None:
106
+ json.dump({"error": message, "code": code}, sys.stderr, ensure_ascii=False)
107
+ sys.stderr.write("\n")
108
+ sys.exit(2)
109
+
110
+
111
+ def _clamp(value: float, lo: float, hi: float) -> float:
112
+ return max(lo, min(hi, value))
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # Minimal TOML parser (Python < 3.11 fallback)
117
+ # ---------------------------------------------------------------------------
118
+
119
+ def _split_preserving_nesting(s: str, sep: str) -> list[str]:
120
+ parts: list[str] = []
121
+ buf: list[str] = []
122
+ depth = 0
123
+ in_str = ""
124
+ for ch in s:
125
+ if in_str:
126
+ buf.append(ch)
127
+ if ch == in_str:
128
+ in_str = ""
129
+ continue
130
+ if ch in ('"', "'"):
131
+ in_str = ch
132
+ buf.append(ch)
133
+ continue
134
+ if ch in ("[", "{"):
135
+ depth += 1
136
+ buf.append(ch)
137
+ continue
138
+ if ch in ("]", "}"):
139
+ depth -= 1
140
+ buf.append(ch)
141
+ continue
142
+ if ch == sep and depth == 0:
143
+ parts.append("".join(buf))
144
+ buf = []
145
+ continue
146
+ buf.append(ch)
147
+ if buf:
148
+ parts.append("".join(buf))
149
+ return parts
150
+
151
+
152
+ def _decode_toml_value(raw: str) -> Any:
153
+ s = raw.strip()
154
+ if not s:
155
+ return ""
156
+ if len(s) >= 2 and s[0] == '"' and s[-1] == '"':
157
+ return s[1:-1]
158
+ if len(s) >= 2 and s[0] == "'" and s[-1] == "'":
159
+ return s[1:-1]
160
+ if s == "true":
161
+ return True
162
+ if s == "false":
163
+ return False
164
+ if s.startswith("["):
165
+ idx = s.rfind("]")
166
+ if idx < 0:
167
+ return []
168
+ inner = s[1:idx].strip()
169
+ if not inner:
170
+ return []
171
+ items = []
172
+ for item in _split_preserving_nesting(inner, ","):
173
+ item = item.strip()
174
+ if item and item[0] != "#":
175
+ items.append(_decode_toml_value(item))
176
+ return items
177
+ if s.startswith("{"):
178
+ idx = s.rfind("}")
179
+ if idx < 0:
180
+ return {}
181
+ inner = s[1:idx].strip()
182
+ result: dict[str, Any] = {}
183
+ for pair in _split_preserving_nesting(inner, ","):
184
+ pair = pair.strip()
185
+ if "=" in pair:
186
+ k, _, v = pair.partition("=")
187
+ result[k.strip()] = _decode_toml_value(v.strip())
188
+ return result
189
+ try:
190
+ return int(s) if "." not in s else float(s)
191
+ except ValueError:
192
+ return s
193
+
194
+
195
+ def _loads_toml_fallback(content: str) -> dict[str, Any]:
196
+ """Parse the TOML subset found in pyproject.toml / Cargo.toml."""
197
+ root: dict[str, Any] = {}
198
+ current = root
199
+ lines = content.split("\n")
200
+ i = 0
201
+ while i < len(lines):
202
+ raw = lines[i]
203
+ i += 1
204
+ stripped = raw.strip()
205
+ if not stripped or stripped[0] == "#":
206
+ continue
207
+
208
+ # [[array.of.tables]]
209
+ if stripped.startswith("[["):
210
+ end = stripped.find("]]")
211
+ if end < 0:
212
+ continue
213
+ path = [p.strip() for p in stripped[2:end].split(".")]
214
+ target = root
215
+ for key in path[:-1]:
216
+ target = target.setdefault(key, {})
217
+ arr = target.setdefault(path[-1], [])
218
+ if not isinstance(arr, list):
219
+ arr = [arr]
220
+ target[path[-1]] = arr
221
+ entry: dict[str, Any] = {}
222
+ arr.append(entry)
223
+ current = entry
224
+ continue
225
+
226
+ # [table]
227
+ if stripped.startswith("[") and not stripped.startswith("[["):
228
+ end = stripped.find("]")
229
+ if end < 0:
230
+ continue
231
+ path = [p.strip() for p in stripped[1:end].split(".")]
232
+ current = root
233
+ for key in path:
234
+ nxt = current.setdefault(key, {})
235
+ if not isinstance(nxt, dict):
236
+ nxt = {}
237
+ current[key] = nxt
238
+ current = nxt
239
+ continue
240
+
241
+ # key = value
242
+ eq = stripped.find("=")
243
+ if eq < 0:
244
+ continue
245
+ key = stripped[:eq].strip().strip('"')
246
+ val_str = stripped[eq + 1:].strip()
247
+
248
+ # Remove trailing comment outside strings
249
+ if val_str and val_str[0] not in ('"', "'", "[", "{"):
250
+ ci = val_str.find(" #")
251
+ if ci >= 0:
252
+ val_str = val_str[:ci].strip()
253
+
254
+ # Multi-line array
255
+ if val_str.startswith("[") and "]" not in val_str:
256
+ while i < len(lines):
257
+ val_str += " " + lines[i].strip()
258
+ i += 1
259
+ if "]" in val_str:
260
+ break
261
+
262
+ current[key] = _decode_toml_value(val_str)
263
+ return root
264
+
265
+
266
+ def _parse_toml(content: str) -> dict[str, Any]:
267
+ if tomllib is not None:
268
+ return tomllib.loads(content)
269
+ return _loads_toml_fallback(content)
270
+
271
+
272
+ # ---------------------------------------------------------------------------
273
+ # Manifest parsers — each returns a normalised dict
274
+ # ---------------------------------------------------------------------------
275
+
276
+ def _parse_package_json(path: Path) -> dict[str, Any]:
277
+ try:
278
+ content = path.read_text(encoding="utf-8")
279
+ data = json.loads(content)
280
+ except (OSError, json.JSONDecodeError) as exc:
281
+ _die(f"Cannot parse {path.as_posix()}: {exc}", "MANIFEST_PARSE_ERROR")
282
+ return {} # unreachable
283
+
284
+ if not isinstance(data, dict):
285
+ _die(f"Expected JSON object in {path.as_posix()}, got {type(data).__name__}", "MANIFEST_PARSE_ERROR")
286
+ return {} # unreachable
287
+
288
+ deps: set[str] = set()
289
+ for key in ("dependencies", "devDependencies", "peerDependencies"):
290
+ section = data.get(key)
291
+ if isinstance(section, dict):
292
+ deps.update(section)
293
+
294
+ exports_field = data.get("exports")
295
+ export_count = 0
296
+ if isinstance(exports_field, dict):
297
+ export_count = len(exports_field)
298
+ elif isinstance(exports_field, str):
299
+ export_count = 1
300
+ elif data.get("main") or data.get("module"):
301
+ export_count = 1
302
+
303
+ return {
304
+ "ecosystem": "npm",
305
+ "name": data.get("name", ""),
306
+ "deps": deps,
307
+ "has_bin": bool(data.get("bin")),
308
+ "has_library_structure": bool(
309
+ data.get("main") or data.get("module") or exports_field
310
+ ),
311
+ "export_count": export_count,
312
+ }
313
+
314
+
315
+ def _dep_name_from_pep508(spec: str) -> str:
316
+ """Extract package name from a PEP 508 dependency string."""
317
+ for ch in (">", "<", "=", "!", "[", ";", " "):
318
+ spec = spec.split(ch, 1)[0]
319
+ return spec.strip().lower()
320
+
321
+
322
+ def _parse_pyproject_toml(path: Path) -> dict[str, Any]:
323
+ try:
324
+ content = path.read_text(encoding="utf-8")
325
+ data = _parse_toml(content)
326
+ except OSError as exc:
327
+ _die(f"Cannot read {path.as_posix()}: {exc}", "MANIFEST_READ_ERROR")
328
+ return {}
329
+ except Exception as exc:
330
+ _die(f"Cannot parse {path.as_posix()}: {exc}", "MANIFEST_PARSE_ERROR")
331
+ return {}
332
+
333
+ project = data.get("project", {})
334
+ if not isinstance(project, dict):
335
+ project = {}
336
+
337
+ deps: set[str] = set()
338
+ for raw in project.get("dependencies", []):
339
+ if isinstance(raw, str):
340
+ name = _dep_name_from_pep508(raw)
341
+ if name:
342
+ deps.add(name)
343
+ poetry_deps = (
344
+ data.get("tool", {}).get("poetry", {}).get("dependencies", {})
345
+ )
346
+ if isinstance(poetry_deps, dict):
347
+ deps.update(k.lower() for k in poetry_deps if k.lower() != "python")
348
+
349
+ scripts = project.get("scripts", {})
350
+ gui_scripts = project.get("gui-scripts", {})
351
+ if not isinstance(scripts, dict):
352
+ scripts = {}
353
+ if not isinstance(gui_scripts, dict):
354
+ gui_scripts = {}
355
+ export_count = len(scripts) + len(gui_scripts)
356
+ if export_count == 0 and project.get("name"):
357
+ export_count = 1
358
+
359
+ return {
360
+ "ecosystem": "python",
361
+ "name": project.get("name", ""),
362
+ "deps": deps,
363
+ "has_bin": False,
364
+ "has_library_structure": bool(project.get("name")),
365
+ "export_count": export_count,
366
+ }
367
+
368
+
369
+ def _parse_cargo_toml(path: Path) -> dict[str, Any]:
370
+ try:
371
+ content = path.read_text(encoding="utf-8")
372
+ data = _parse_toml(content)
373
+ except OSError as exc:
374
+ _die(f"Cannot read {path.as_posix()}: {exc}", "MANIFEST_READ_ERROR")
375
+ return {}
376
+ except Exception as exc:
377
+ _die(f"Cannot parse {path.as_posix()}: {exc}", "MANIFEST_PARSE_ERROR")
378
+ return {}
379
+
380
+ pkg = data.get("package", {})
381
+ if not isinstance(pkg, dict):
382
+ pkg = {}
383
+
384
+ deps: set[str] = set()
385
+ for dep_key in ("dependencies", "dev-dependencies", "build-dependencies"):
386
+ section = data.get(dep_key, {})
387
+ if isinstance(section, dict):
388
+ deps.update(k.lower() for k in section)
389
+
390
+ has_lib = "lib" in data and isinstance(data["lib"], dict)
391
+ bin_targets = data.get("bin", [])
392
+ if not isinstance(bin_targets, list):
393
+ bin_targets = []
394
+ has_bin = len(bin_targets) > 0
395
+
396
+ export_count = (1 if has_lib else 0) + len(bin_targets)
397
+ if export_count == 0 and pkg.get("name"):
398
+ export_count = 1
399
+
400
+ workspace_members = data.get("workspace", {}).get("members", [])
401
+ if not isinstance(workspace_members, list):
402
+ workspace_members = []
403
+
404
+ return {
405
+ "ecosystem": "rust",
406
+ "name": pkg.get("name", ""),
407
+ "deps": deps,
408
+ "has_bin": has_bin,
409
+ "has_library_structure": has_lib or bool(pkg.get("name")),
410
+ "export_count": export_count,
411
+ }
412
+
413
+
414
+ _PARSERS = {
415
+ "package.json": _parse_package_json,
416
+ "pyproject.toml": _parse_pyproject_toml,
417
+ "Cargo.toml": _parse_cargo_toml,
418
+ }
419
+
420
+
421
+ def _parse_manifest(path: Path) -> dict[str, Any]:
422
+ parser = _PARSERS.get(path.name)
423
+ if parser is None:
424
+ _die(f"Unsupported manifest type: {path.name}", "UNSUPPORTED_MANIFEST")
425
+ return parser(path)
426
+
427
+
428
+ # ---------------------------------------------------------------------------
429
+ # Core classification
430
+ # ---------------------------------------------------------------------------
431
+
432
+ def detect(repo_url: str, manifest_paths: list[str]) -> dict[str, Any]:
433
+ """Classify a repo into a skill shape from its manifest files."""
434
+ if not manifest_paths:
435
+ _die("--manifests requires at least one path", "MISSING_MANIFESTS")
436
+
437
+ parsed: list[dict[str, Any]] = []
438
+ for mp in manifest_paths:
439
+ p = Path(mp)
440
+ if not p.is_file():
441
+ _die(f"Manifest not found: {p.as_posix()}", "MANIFEST_NOT_FOUND")
442
+ parsed.append(_parse_manifest(p))
443
+
444
+ all_deps: set[str] = set()
445
+ ecosystems: set[str] = set()
446
+ total_exports = 0
447
+ signals: list[str] = []
448
+ has_bin = False
449
+ has_library_structure = False
450
+
451
+ for m in parsed:
452
+ eco = m["ecosystem"]
453
+ ecosystems.add(eco)
454
+ signals.append(f"has_{path_to_manifest_name(eco)}")
455
+ all_deps.update(m.get("deps", set()))
456
+ total_exports += m.get("export_count", 0)
457
+ if m.get("has_bin"):
458
+ has_bin = True
459
+ if m.get("has_library_structure"):
460
+ has_library_structure = True
461
+
462
+ package_count = len(parsed)
463
+
464
+ if total_exports > 50:
465
+ signals.append("exports_count_gt_50")
466
+ if has_bin:
467
+ signals.append("has_bin_field")
468
+ elif has_library_structure:
469
+ signals.append("no_bin_field")
470
+ if has_library_structure:
471
+ signals.append("has_library_structure")
472
+
473
+ # Collect dep-category matches
474
+ parser_deps = sorted(d for d in all_deps if d.lower() in _ALL_PARSER_DEPS)
475
+ framework_deps = sorted(d for d in all_deps if d.lower() in _ALL_FRAMEWORK_DEPS)
476
+ has_framework = len(framework_deps) > 0
477
+
478
+ for d in parser_deps:
479
+ signals.append(f"parser_dep:{d}")
480
+ for d in framework_deps:
481
+ signals.append(f"framework_dep:{d}")
482
+ if len(ecosystems) > 1:
483
+ signals.append("multiple_ecosystems")
484
+ for eco in sorted(ecosystems):
485
+ signals.append(f"ecosystem:{eco}")
486
+
487
+ result_base = {
488
+ "export_count": total_exports,
489
+ "package_count": package_count,
490
+ }
491
+
492
+ # --- Heuristic ladder (first match wins) ---
493
+
494
+ # 1. language-reference
495
+ if parser_deps:
496
+ confidence = _clamp(0.75 + len(parser_deps) * 0.05, 0.75, 0.85)
497
+ return {"shape": "language-reference", "signals": signals,
498
+ "confidence": round(confidence, 2), **result_base}
499
+
500
+ # 2. stack-compose
501
+ if len(ecosystems) > 1:
502
+ confidence = _clamp(0.80 + (len(ecosystems) - 2) * 0.05, 0.80, 0.90)
503
+ return {"shape": "stack-compose", "signals": signals,
504
+ "confidence": round(confidence, 2), **result_base}
505
+
506
+ # 3. reference-app
507
+ if has_bin or has_framework:
508
+ strength = (1 if has_bin else 0) + (1 if has_framework else 0)
509
+ confidence = _clamp(0.80 + (strength - 1) * 0.05, 0.80, 0.90)
510
+ return {"shape": "reference-app", "signals": signals,
511
+ "confidence": round(confidence, 2), **result_base}
512
+
513
+ # 4. library-API
514
+ if has_library_structure or total_exports > 0:
515
+ base = 0.65
516
+ if has_library_structure:
517
+ base = 0.80
518
+ if total_exports > 50:
519
+ base = max(base, 0.90)
520
+ elif total_exports > 10:
521
+ base = max(base, 0.80)
522
+ confidence = _clamp(base, 0.65, 0.95)
523
+ return {"shape": "library-API", "signals": signals,
524
+ "confidence": round(confidence, 2), **result_base}
525
+
526
+ # 5. unknown
527
+ return {"shape": "unknown", "signals": signals,
528
+ "confidence": 0.0, **result_base}
529
+
530
+
531
+ def path_to_manifest_name(ecosystem: str) -> str:
532
+ return {"npm": "package_json", "python": "pyproject_toml",
533
+ "rust": "cargo_toml"}.get(ecosystem, ecosystem)
534
+
535
+
536
+ # ---------------------------------------------------------------------------
537
+ # CLI wiring
538
+ # ---------------------------------------------------------------------------
539
+
540
+ def main(argv: list[str]) -> int:
541
+ parser = argparse.ArgumentParser(
542
+ description="Classify a repo into a known skill shape from its manifest files.",
543
+ )
544
+ parser.add_argument("--repo-url", required=True, help="Repository URL")
545
+ parser.add_argument(
546
+ "--manifests", required=True,
547
+ help="Comma-separated local file paths to manifest files",
548
+ )
549
+ args = parser.parse_args(argv)
550
+
551
+ manifest_paths = [p.strip() for p in args.manifests.split(",") if p.strip()]
552
+ if not manifest_paths:
553
+ _die("--manifests requires at least one path", "MISSING_MANIFESTS")
554
+
555
+ result = detect(args.repo_url, manifest_paths)
556
+ json.dump(result, sys.stdout, ensure_ascii=False)
557
+ sys.stdout.write("\n")
558
+
559
+ return 1 if result["shape"] == "unknown" else 0
560
+
561
+
562
+ if __name__ == "__main__":
563
+ sys.exit(main(sys.argv[1:]))
@@ -21,6 +21,7 @@ system-wide:
21
21
  uv run skf-validate-frontmatter.py <skill-md-path>
22
22
  uv run skf-validate-frontmatter.py <skill-md-path> --skill-dir-name <name>
23
23
  uv run skf-validate-frontmatter.py <skill-md-path> --max-body-lines 500
24
+ uv run skf-validate-frontmatter.py <skill-md-path> --max-body-lines 500 --max-body-tokens 5000
24
25
 
25
26
  Input:
26
27
  Path to a SKILL.md file.
@@ -31,6 +32,11 @@ Input:
31
32
  exceeds N lines. Opt-in — when omitted, body size is never checked and
32
33
  the verdict is unchanged. Callers pass the skill-check `body.max_lines`
33
34
  default (500) to pre-catch that hard reject before commit.
35
+ Optional --max-body-tokens N: when set, emit a high-severity `body`
36
+ issue if the estimated body token count exceeds N. Token count is
37
+ estimated as ceil(character_count / 4). Callers pass the skill-check
38
+ `body.max_tokens` default (5000) to pre-catch that soft/hard reject
39
+ before commit.
34
40
 
35
41
  Output:
36
42
  JSON object:
@@ -154,6 +160,28 @@ def body_line_count(content: str) -> int | None:
154
160
  return len(lines) - (closing + 1)
155
161
 
156
162
 
163
+ def body_token_estimate(content: str) -> int | None:
164
+ """Estimate the SKILL.md body token count as ceil(char_count / 4).
165
+
166
+ Returns None when the frontmatter delimiters are absent or unclosed
167
+ (same boundary condition as body_line_count). The heuristic matches
168
+ the agentskills.io spec's token estimation convention.
169
+ """
170
+ if not content.startswith("---\n") and not content.startswith("---\r\n"):
171
+ return None
172
+ lines = content.splitlines()
173
+ closing = -1
174
+ for i in range(1, len(lines)):
175
+ if lines[i] == "---":
176
+ closing = i
177
+ break
178
+ if closing == -1:
179
+ return None
180
+ body = "\n".join(lines[closing + 1:])
181
+ char_count = len(body)
182
+ return -(-char_count // 4) # ceil division
183
+
184
+
157
185
  def _validate_name(name: str, skill_dir_name: str | None) -> list[dict]:
158
186
  """Validate skill name format. Aligned with canonical validator.py."""
159
187
  issues: list[dict] = []
@@ -220,14 +248,16 @@ def validate_frontmatter(
220
248
  content: str,
221
249
  skill_dir_name: str | None = None,
222
250
  max_body_lines: int | None = None,
251
+ max_body_tokens: int | None = None,
223
252
  ) -> dict:
224
253
  """Validate SKILL.md frontmatter against agentskills.io spec.
225
254
 
226
255
  When `max_body_lines` is set, additionally emit a high-severity `body`
227
- issue if the body exceeds that many lines (opt-in the verdict is
228
- unchanged when it is None, so the other consumers of this validator are
229
- unaffected). Returns a result dict with status, issues, frontmatter,
230
- body_lines, and summary.
256
+ issue if the body exceeds that many lines. When `max_body_tokens` is
257
+ set, emit a high-severity `body` issue if the estimated token count
258
+ exceeds that limit. Both are opt-in the verdict is unchanged when
259
+ they are None. Returns a result dict with status, issues, frontmatter,
260
+ body_lines, body_tokens, and summary.
231
261
  """
232
262
  fm, issues = parse_frontmatter(content)
233
263
 
@@ -239,6 +269,14 @@ def validate_frontmatter(
239
269
  "message": f"body lines {body_lines} exceeds max {max_body_lines}",
240
270
  })
241
271
 
272
+ body_tokens = body_token_estimate(content)
273
+ if max_body_tokens is not None and body_tokens is not None and body_tokens > max_body_tokens:
274
+ issues.append({
275
+ "severity": "high",
276
+ "field": "body",
277
+ "message": f"body token estimate {body_tokens} exceeds max {max_body_tokens}",
278
+ })
279
+
242
280
  if fm is not None:
243
281
  # Name validation
244
282
  name = fm.get("name", "")
@@ -301,6 +339,7 @@ def validate_frontmatter(
301
339
  "issues": issues,
302
340
  "frontmatter": fm,
303
341
  "body_lines": body_lines,
342
+ "body_tokens": body_tokens,
304
343
  "summary": {
305
344
  "total": len(issues),
306
345
  **severity_counts,
@@ -345,6 +384,18 @@ def main() -> int:
345
384
  "the skill-check body.max_lines default (500) to pre-catch that reject."
346
385
  ),
347
386
  )
387
+ parser.add_argument(
388
+ "--max-body-tokens",
389
+ metavar="N",
390
+ type=int,
391
+ default=None,
392
+ help=(
393
+ "when set, emit a high-severity 'body' issue if the estimated body "
394
+ "token count exceeds N (opt-in; omitted = body tokens not checked). "
395
+ "Token estimate uses ceil(char_count / 4). Pass the skill-check "
396
+ "body.max_tokens default (5000) to pre-catch that reject."
397
+ ),
398
+ )
348
399
  parser.add_argument(
349
400
  "-o", "--output",
350
401
  metavar="FILE",
@@ -365,7 +416,7 @@ def main() -> int:
365
416
  else:
366
417
  content = skill_md_path.read_text(encoding="utf-8")
367
418
  skill_dir_name = args.skill_dir_name or skill_md_path.parent.name
368
- result = validate_frontmatter(content, skill_dir_name, args.max_body_lines)
419
+ result = validate_frontmatter(content, skill_dir_name, args.max_body_lines, args.max_body_tokens)
369
420
 
370
421
  output_text = json.dumps(result, indent=2)
371
422