bmad-module-skill-forge 1.8.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 (43) 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/skf-detect-docs.py +412 -0
  12. package/src/shared/scripts/skf-emit-brief-result-envelope.py +12 -1
  13. package/src/shared/scripts/skf-preapply.py +192 -0
  14. package/src/shared/scripts/skf-shape-detect.py +563 -0
  15. package/src/shared/scripts/skf-validate-pins.py +368 -0
  16. package/src/skf-analyze-source/SKILL.md +26 -20
  17. package/src/skf-analyze-source/references/init.md +30 -0
  18. package/src/skf-analyze-source/references/step-auto-scope.md +525 -0
  19. package/src/skf-analyze-source/references/step-shape-detect.md +79 -0
  20. package/src/skf-audit-skill/SKILL.md +1 -0
  21. package/src/skf-audit-skill/assets/drift-report-template.md +6 -0
  22. package/src/skf-audit-skill/references/report.md +4 -0
  23. package/src/skf-audit-skill/references/severity-classify.md +1 -1
  24. package/src/skf-audit-skill/references/step-doc-drift.md +147 -0
  25. package/src/skf-brief-skill/SKILL.md +7 -3
  26. package/src/skf-brief-skill/references/gather-intent.md +14 -0
  27. package/src/skf-brief-skill/references/step-auto-brief.md +171 -0
  28. package/src/skf-brief-skill/references/step-auto-validate.md +209 -0
  29. package/src/skf-create-skill/SKILL.md +3 -0
  30. package/src/skf-create-skill/assets/skill-sections.md +2 -0
  31. package/src/skf-create-skill/references/compile.md +1 -1
  32. package/src/skf-create-skill/references/step-auto-shard.md +110 -0
  33. package/src/skf-create-skill/references/step-doc-rot.md +109 -0
  34. package/src/skf-create-skill/references/step-doc-sources.md +114 -0
  35. package/src/skf-forger/SKILL.md +8 -2
  36. package/src/skf-test-skill/SKILL.md +8 -7
  37. package/src/skf-test-skill/customize.toml +2 -1
  38. package/src/skf-test-skill/references/external-validators.md +1 -1
  39. package/src/skf-test-skill/references/init.md +16 -1
  40. package/src/skf-test-skill/references/report.md +5 -1
  41. package/src/skf-test-skill/references/score.md +95 -6
  42. package/src/skf-test-skill/references/step-hard-gate.md +73 -0
  43. 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:]))