skilldrop-cli 0.16.43 → 0.16.45

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 (74) hide show
  1. package/package.json +1 -1
  2. package/packs/ai-engineering/skills/agent-config-review/manifest.json +1 -1
  3. package/packs/ai-engineering/skills/agent-config-review/scripts/agent_config_scan.py +6 -1
  4. package/packs/ai-engineering/skills/ai-usage-report/manifest.json +1 -1
  5. package/packs/ai-engineering/skills/ai-usage-report/scripts/build_report.py +30 -13
  6. package/packs/career/skills/cover-letter/manifest.json +1 -1
  7. package/packs/career/skills/cover-letter/scripts/claim_check.py +25 -3
  8. package/packs/career/skills/resume-tailor/manifest.json +1 -1
  9. package/packs/career/skills/resume-tailor/scripts/claim_check.py +25 -3
  10. package/packs/converters/skills/file-to-markdown/manifest.json +1 -1
  11. package/packs/converters/skills/file-to-markdown/scripts/to_markdown.py +4 -2
  12. package/packs/converters/skills/md-to-confluence/manifest.json +1 -1
  13. package/packs/converters/skills/md-to-confluence/scripts/md_to_confluence.py +8 -5
  14. package/packs/converters/skills/md-to-html/manifest.json +1 -1
  15. package/packs/converters/skills/md-to-html/scripts/md_to_html.py +10 -0
  16. package/packs/converters/skills/md-to-pptx/manifest.json +1 -1
  17. package/packs/converters/skills/md-to-pptx/scripts/md_to_pptx.py +5 -4
  18. package/packs/converters/skills/mermaid-render/manifest.json +1 -1
  19. package/packs/converters/skills/mermaid-render/scripts/render_mermaid.py +2 -1
  20. package/packs/core/skills/output-hygiene/manifest.json +1 -1
  21. package/packs/core/skills/output-hygiene/scripts/scrub.py +3 -2
  22. package/packs/data-analytics/skills/sql-review/manifest.json +1 -1
  23. package/packs/data-analytics/skills/sql-review/scripts/sql_lint.py +10 -3
  24. package/packs/design/skills/brand-kit/manifest.json +1 -1
  25. package/packs/design/skills/brand-kit/scripts/check_brand.py +22 -6
  26. package/packs/design/skills/deck-builder/manifest.json +1 -1
  27. package/packs/design/skills/deck-builder/scripts/build_deck.py +19 -9
  28. package/packs/design/skills/marketing-flyer/manifest.json +1 -1
  29. package/packs/design/skills/marketing-flyer/scripts/build_flyer.py +10 -1
  30. package/packs/dev-team/skills/behaviour-equivalence/manifest.json +1 -1
  31. package/packs/dev-team/skills/behaviour-equivalence/scripts/golden_diff.py +14 -2
  32. package/packs/dev-team/skills/codebase-tour/manifest.json +1 -1
  33. package/packs/dev-team/skills/codebase-tour/scripts/repo_facts.py +6 -1
  34. package/packs/dev-team/skills/dependency-upgrade-review/manifest.json +1 -1
  35. package/packs/dev-team/skills/dependency-upgrade-review/scripts/upgrade_diff.py +2 -2
  36. package/packs/dev-team/skills/pre-merge-review/manifest.json +1 -1
  37. package/packs/dev-team/skills/pre-merge-review/scripts/gate.py +7 -1
  38. package/packs/dev-team/skills/rollout-plan/manifest.json +1 -1
  39. package/packs/dev-team/skills/rollout-plan/scripts/canary_math.py +2 -2
  40. package/packs/frontend/skills/bundle-size-review/manifest.json +1 -1
  41. package/packs/frontend/skills/bundle-size-review/scripts/bundle_stats.py +14 -2
  42. package/packs/frontend/skills/web-performance-review/manifest.json +1 -1
  43. package/packs/frontend/skills/web-performance-review/scripts/lh_summary.py +18 -3
  44. package/packs/grc/skills/risk-register/manifest.json +1 -1
  45. package/packs/grc/skills/risk-register/scripts/check_register.py +15 -2
  46. package/packs/infra-as-code/skills/terraform-plan-review/manifest.json +1 -1
  47. package/packs/infra-as-code/skills/terraform-plan-review/scripts/plan_summary.py +15 -3
  48. package/packs/product-manager/skills/feedback-synthesis/manifest.json +1 -1
  49. package/packs/product-manager/skills/feedback-synthesis/scripts/theme_counts.py +6 -1
  50. package/packs/product-manager/skills/lean-canvas/manifest.json +1 -1
  51. package/packs/product-manager/skills/lean-canvas/scripts/unit_economics.py +1 -1
  52. package/packs/research/skills/hypothesis-comparison/manifest.json +1 -1
  53. package/packs/research/skills/hypothesis-comparison/scripts/ach_matrix.py +13 -2
  54. package/packs/research/skills/source-synthesis/manifest.json +1 -1
  55. package/packs/research/skills/source-synthesis/scripts/citations.py +2 -1
  56. package/packs/skill-engineering/skills/skill-review/manifest.json +1 -1
  57. package/packs/skill-engineering/skills/skill-review/scripts/lint_skill.py +4 -1
  58. package/packs/solution-architect/skills/event-contract-draft/manifest.json +1 -1
  59. package/packs/solution-architect/skills/event-contract-draft/scripts/check_asyncapi.py +23 -4
  60. package/packs/solution-architect/skills/figma-diagrams/manifest.json +1 -1
  61. package/packs/solution-architect/skills/figma-diagrams/scripts/frame_to_mermaid.py +1 -1
  62. package/packs/solution-architect/skills/figma-diagrams/scripts/post_comment.py +1 -1
  63. package/packs/solution-architect/skills/interactive-diagram/manifest.json +1 -1
  64. package/packs/solution-architect/skills/interactive-diagram/scripts/build_diagram.py +5 -2
  65. package/packs/sre-oncall/skills/cloud-cost-review/manifest.json +1 -1
  66. package/packs/sre-oncall/skills/cloud-cost-review/scripts/cost_summary.py +7 -2
  67. package/packs/sre-oncall/skills/delivery-metrics-report/manifest.json +1 -1
  68. package/packs/sre-oncall/skills/delivery-metrics-report/scripts/delivery_metrics.py +4 -0
  69. package/packs/trackers/skills/backlog-triage/manifest.json +1 -1
  70. package/packs/trackers/skills/backlog-triage/scripts/triage_backlog.py +11 -3
  71. package/packs/trackers/skills/team-status-report/manifest.json +1 -1
  72. package/packs/trackers/skills/team-status-report/scripts/status_counts.py +9 -2
  73. package/packs/trackers/skills/tracker-brief-sync/manifest.json +1 -1
  74. package/packs/trackers/skills/tracker-brief-sync/scripts/brief_sync.py +1 -1
@@ -34,13 +34,17 @@ import zipfile
34
34
  from dataclasses import dataclass
35
35
  from pathlib import Path
36
36
 
37
- from pptx import Presentation
38
- from pptx.chart.data import CategoryChartData
39
- from pptx.dml.color import RGBColor
40
- from pptx.enum.chart import XL_CHART_TYPE, XL_LEGEND_POSITION
41
- from pptx.enum.shapes import MSO_SHAPE, PP_PLACEHOLDER
42
- from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
43
- from pptx.util import Emu, Inches, Pt
37
+ try:
38
+ from pptx import Presentation
39
+ from pptx.chart.data import CategoryChartData
40
+ from pptx.dml.color import RGBColor
41
+ from pptx.enum.chart import XL_CHART_TYPE, XL_LEGEND_POSITION
42
+ from pptx.enum.shapes import MSO_SHAPE, PP_PLACEHOLDER
43
+ from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
44
+ from pptx.util import Emu, Inches, Pt
45
+ except ImportError:
46
+ sys.stderr.write("error: python-pptx is not installed; install it with: pip install python-pptx\n")
47
+ sys.exit(1)
44
48
 
45
49
  # The design canvas every hardcoded position below is expressed in. Real slide
46
50
  # geometry comes from Canvas, which scales these to the actual deck size.
@@ -960,7 +964,8 @@ def load_brand(ref, base: Path):
960
964
  return {}, base
961
965
  if isinstance(ref, dict):
962
966
  return ref, base
963
- path = (base / ref).expanduser() if not Path(ref).is_absolute() else Path(ref)
967
+ path = Path(ref).expanduser()
968
+ path = path if path.is_absolute() else base / path
964
969
  if not path.exists():
965
970
  warn(f"brand file not found: {path}; building without it")
966
971
  return {}, base
@@ -1024,7 +1029,12 @@ def place_logo(slide, c: "Canvas", palette: dict, logical: str) -> None:
1024
1029
 
1025
1030
 
1026
1031
  def build(spec_path: Path, out_path: Path, script_dir: Path, *, strict: bool, brand_ref=None) -> None:
1027
- spec = json.loads(spec_path.read_text(encoding="utf-8"))
1032
+ try:
1033
+ spec = json.loads(spec_path.read_text(encoding="utf-8-sig"))
1034
+ except json.JSONDecodeError as e:
1035
+ die(f"spec {spec_path} is not valid JSON ({e}); fix it and re-run")
1036
+ except UnicodeDecodeError:
1037
+ die(f"spec {spec_path} is not UTF-8 text; save it as UTF-8 and re-run")
1028
1038
  spec_dir = spec_path.parent
1029
1039
  brand, brand_dir = load_brand(brand_ref or spec.get("brand"), spec_dir)
1030
1040
  if brand:
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "marketing-flyer",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Generate a print-ready, on-brand marketing flyer — event, launch, open day, offer, hiring — as one self-contained HTML file (Letter, A4, A5, or square and story sizes for social) that saves to PDF, built from the user's own logo, colours, fonts and content, with one call to action, a short headline, contrast checked, and no invented dates, prices or contact details. Use when the user wants a flyer, poster, one-pager, handout, leaflet or event announcement \"in our branding\", or says \"make a flyer for…\".",
5
5
  "entrypoint": "SKILL.md",
6
6
  "deps": {
@@ -79,9 +79,14 @@ def data_uri(path, label):
79
79
 
80
80
  def load_json(path):
81
81
  try:
82
- return json.loads(Path(path).read_text(encoding="utf-8"))
82
+ data = json.loads(Path(path).read_text(encoding="utf-8-sig"))
83
83
  except (OSError, json.JSONDecodeError) as e:
84
84
  die(f"cannot read {path}: {e}")
85
+ except UnicodeDecodeError:
86
+ die(f"cannot read {path}: it is not UTF-8 text; save it as UTF-8")
87
+ if not isinstance(data, dict):
88
+ die(f"cannot read {path}: expected a JSON object {{...}}, got a {type(data).__name__}")
89
+ return data
85
90
 
86
91
 
87
92
  def resolve(base, ref):
@@ -164,9 +169,13 @@ def build(spec_path, out_path, brand_ref=None):
164
169
  if isinstance(cta, list):
165
170
  warn("more than one call to action; a flyer gets one — using the first")
166
171
  cta = cta[0] if cta else None
172
+ if isinstance(cta, str):
173
+ cta = {"text": cta}
167
174
  if not cta:
168
175
  warn("no call to action — what should a reader do next?")
169
176
  img = spec.get("image") or {}
177
+ if isinstance(img, str):
178
+ img = {"path": img}
170
179
  hero = data_uri(resolve(base, img.get("path")), "image") if isinstance(img, dict) else data_uri(resolve(base, img), "image")
171
180
  if hero and isinstance(img, dict) and not img.get("alt"):
172
181
  warn("image has no alt text")
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "behaviour-equivalence",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Prove that a rewritten, migrated or refactored component behaves the same as the old one with a golden-master comparison — record inputs and outputs at a boundary (HTTP, function calls, batch files, DB writes, events) from production-shaped traffic and edge cases, run the old system twice to find its nondeterminism, normalise narrowly field by field, replay against the new one, triage every difference as defect, intended change (with a named approver) or missing normalisation, and give a verdict of EQUIVALENT, EQUIVALENT EXCEPT LISTED CHANGES, NOT EQUIVALENT or NOT PROVEN. Use when the user asks \"does the new one behave the same as the old one\", \"can we sign off the rewrite\", \"are these diffs just noise\", wants a golden-master, approval or shadow-traffic comparison, a parity check between old and new outputs, or says \"prove behaviour equivalence\".",
5
5
  "entrypoint": "SKILL.md",
6
6
  "deps": { "npm": [], "pip": [] },
@@ -143,7 +143,9 @@ def load_rules(path):
143
143
  items = raw["rules"]
144
144
  elif isinstance(raw, dict):
145
145
  items = []
146
- for p, spec in raw.items():
146
+ for i, (p, spec) in enumerate(raw.items(), 1):
147
+ if not isinstance(spec, (str, dict)):
148
+ raise BadInput("rule {} ({}): spec must be a rule name or an object, got {!r}".format(i, p, spec))
147
149
  spec = {"rule": spec} if isinstance(spec, str) else dict(spec)
148
150
  spec["path"] = p
149
151
  items.append(spec)
@@ -493,7 +495,7 @@ def render(rep, max_diffs):
493
495
  return "\n".join(out) + "\n"
494
496
 
495
497
 
496
- def main(argv=None):
498
+ def _main(argv=None):
497
499
  ap = argparse.ArgumentParser(description="Golden-master diff of old vs new recorded outputs.")
498
500
  ap.add_argument("old", help="directory or JSONL file of the old system's outputs")
499
501
  ap.add_argument("new", help="directory or JSONL file of the new system's outputs")
@@ -520,5 +522,15 @@ def main(argv=None):
520
522
  return 0 if rep["verdict"] != "NOT EQUIVALENT" else 1
521
523
 
522
524
 
525
+
526
+ def main(argv=None):
527
+ """Run, and turn an input that parses but has the wrong shape into one clear error."""
528
+ try:
529
+ return _main(argv)
530
+ except (KeyError, TypeError, AttributeError, IndexError, ValueError) as e:
531
+ print("error: couldn't read the input as recordings and rules (%s: %s). If it is one, this is a bug: "
532
+ "please report it with the file." % (type(e).__name__, e), file=sys.stderr)
533
+ return 2
534
+
523
535
  if __name__ == "__main__":
524
536
  sys.exit(main())
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codebase-tour",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Get a newcomer (a new engineer, or the agent itself) oriented in an unfamiliar repository before they make one specific change, with a short tour scoped to that change. It gives the install, run and test commands taken from CI, package scripts and the Makefile; the path the change touches, traced through the code with file:line citations; where the change goes; the conventions the code actually follows; the traps (generated files, a second copy of the logic, untested code, env vars, flags); and a first step that can be checked. Use when someone says \"I'm new to this repo\", \"get me oriented\", \"where would I make this change\", \"walk me through the code for X\", \"onboard me before I start this ticket\", or \"how do I run and test this\".",
5
5
  "entrypoint": "SKILL.md",
6
6
  "deps": {
@@ -164,7 +164,12 @@ def tox_nox(rel, name, lines, out, targets):
164
164
  if sec.group(2):
165
165
  targets["tox"].add(sec.group(2))
166
166
  elif env and re.match(r"^\s*commands\s*=", line):
167
- cmd = line.split("=", 1)[1].strip()
167
+ cmds = [line.split("=", 1)[1].strip()]
168
+ for nxt in lines[i + 1:]:
169
+ if not nxt.strip() or not nxt[0].isspace():
170
+ break
171
+ cmds.append(nxt.strip())
172
+ cmd = "; ".join(c for c in cmds if c and not c.startswith("#"))
168
173
  out.append({"command": "tox -e %s" % env, "runs": cmd, "where": "%s:%d" % (rel, i + 1)})
169
174
  else:
170
175
  for i, line in enumerate(lines):
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dependency-upgrade-review",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Give a merge verdict on a dependency-upgrade pull request (Dependabot, Renovate or a hand-made bump) across npm, pip, Go, Cargo and Maven lockfiles and GitHub Actions pins. Reads the release notes for every skipped version, lists what the lockfile changes beyond the PR title (transitive bumps, new and removed packages, install scripts, registry changes), checks each SHA pin against its release tag and against the PR's other pins, checks raised runtime floors against the images and runners that install it, says whether CI ran the changed code, traces what each caller of a changed API now does and writes the rollback. The reply leads with MERGE, MERGE AFTER CHECKS or DO NOT MERGE and the reasons that decide it. Use when the user says \"review this Dependabot PR\", \"is this Renovate bump safe to merge\", \"can I merge this dependency upgrade\", \"check this lockfile diff\", or \"is this action SHA pin right\".",
5
5
  "entrypoint": "SKILL.md",
6
6
  "deps": {
@@ -333,7 +333,7 @@ def h_package_lock(rep, path, hunks):
333
333
 
334
334
  def h_yarn_lock(rep, path, hunks):
335
335
  def key(text):
336
- m = re.match(r'^"?((?:@[^@/\s"]+/)?[^@\s",]+)@[^:]*"?:\s*$', text)
336
+ m = re.match(r'^"?((?:@[^@/\s"]+/)?[^@\s",]+)@[^"]*"?:\s*$', text)
337
337
  return m.group(1) if m and not text.startswith((" ", "#")) else None
338
338
  fields = {"version": re.compile(r'^\s+version:?\s+"?([^"\s]+)"?'),
339
339
  "resolved": re.compile(r'^\s+resolved:?\s+"?([^"\s#]+)'),
@@ -599,7 +599,7 @@ def analyse(files, title):
599
599
  t = title.lower()
600
600
  for r in rep.changes:
601
601
  short = r["name"].split("/")[-1].lower()
602
- r["in_title"] = r["name"].lower() in t or (len(short) > 3 and re.search(rf"\b{re.escape(short)}\b", t) is not None)
602
+ r["in_title"] = re.search(rf"(?<![\w@/.-]){re.escape(r['name'].lower())}(?![\w-])", t) is not None or (len(short) > 3 and re.search(rf"\b{re.escape(short)}\b", t) is not None)
603
603
  for (repo, tag_), shas in rep.sha_pins.items():
604
604
  if len(shas) > 1:
605
605
  where = "; ".join(f"{s} in {', '.join(dict.fromkeys(ps))}" for s, ps in shas.items())
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pre-merge-review",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Gate an existing change for merge — run one skill that fires the whole production-readiness pass. First a deterministic mechanical gate (lint + typecheck + tests, via a script whose exit code decides), then the three-reviewer panel dispatched in parallel — devils-advocate (correctness), security-reviewer (exploitability), code-quality (craft) — then a single READY / NOT READY verdict. Use before opening a PR or merging, when you want the full review fleet run on a diff you already have. Do NOT use to implement a feature from a spec (that's feature-implement-loop) or to review a document (that's doc-critique).",
5
5
  "entrypoint": "SKILL.md",
6
6
  "deps": { "npm": [], "pip": [] },
@@ -64,7 +64,13 @@ def detect(root):
64
64
 
65
65
 
66
66
  def load_config(path):
67
- data = json.load(open(path, encoding="utf-8"))
67
+ try:
68
+ with open(path, encoding="utf-8-sig") as f:
69
+ data = json.load(f)
70
+ except UnicodeDecodeError:
71
+ raise SystemExit(f"gate: --config {path} is not UTF-8 — save it as UTF-8 and retry")
72
+ except (OSError, ValueError) as exc:
73
+ raise SystemExit(f"gate: cannot read --config {path}: {exc}")
68
74
  cmds = data.get("commands") if isinstance(data, dict) else data
69
75
  if not isinstance(cmds, list) or not all(isinstance(c, str) for c in cmds):
70
76
  raise SystemExit("gate: --config must hold a list of command strings (or {\"commands\": [...]})")
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rollout-plan",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Plan the progressive release of a code change behind a feature flag or canary — the exposure ladder (internal, beta cohort, 1%, 5%, 25%, 50%, 100%, or what the traffic supports), and for each step who is exposed, decision and guardrail metrics with a baseline and a threshold against a control cohort, the minimum traffic and time before the numbers mean anything, a promote / hold / roll back rule set in advance, who watches and what aborts automatically; then what turning the flag off does not undo, and who removes the flag by when. Use when the user asks for a rollout plan, canary plan, ramp schedule, progressive delivery or percentage rollout, \"how long should the canary run\", \"when do we go from 5% to 25%\", or how to turn on a flagged feature safely.",
5
5
  "entrypoint": "SKILL.md",
6
6
  "deps": { "npm": [], "pip": [] },
@@ -161,8 +161,8 @@ def main():
161
161
  p2 = p1 + d if args.direction == "up" else p1 - d
162
162
  if not 0 < p2 < 1:
163
163
  fail("baseline plus the regression falls outside 0-100%")
164
- if args.traffic <= 0:
165
- fail("--traffic must be greater than zero")
164
+ if not math.isfinite(args.traffic) or args.traffic <= 0:
165
+ fail("--traffic must be a finite number greater than zero")
166
166
  if not 0 < args.alpha < 1 or not 0 < args.power < 1:
167
167
  fail("--alpha and --power must be between 0 and 1")
168
168
  if args.max_hours <= 0:
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bundle-size-review",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Find what makes a JavaScript bundle heavy and what can safely come out, from the bundler's stats file (webpack stats.json, an esbuild metafile, or the rollup-plugin-visualizer raw-data JSON that Vite builds use) read together with the repo around it (package.json, .browserslistrc, the lockfile, the bundler config and the source files that import the heavy modules). Gives each entry's first-load JavaScript in the exact bytes the stats record, kept apart from lazy chunks; finds whole-library imports, CommonJS packages that can't be tree-shaken, packages bundled twice and polyfills the browser targets don't need; names the import chain that pulls each module in before suggesting a removal; and, given before and after stats, says what grew, why, whether it lands in the first load and which per-entry budget would have caught it. Use when the user says \"why is our bundle so big\", \"what can we cut from the bundle\", \"review this stats.json\", \"why is lodash or moment in our bundle\", \"the bundle grew in this PR\", or \"set a bundle size budget\".",
5
5
  "entrypoint": "SKILL.md",
6
6
  "deps": {
@@ -500,6 +500,8 @@ def load(path):
500
500
  text = fh.read()
501
501
  except OSError as e:
502
502
  raise InputError("cannot read %s: %s" % (path, e))
503
+ except UnicodeDecodeError:
504
+ raise InputError("%s is not UTF-8 text; save it as UTF-8" % path)
503
505
  if not text.strip():
504
506
  raise InputError("%s is empty" % path)
505
507
  try:
@@ -1023,7 +1025,7 @@ def parse_browserslist(queries):
1023
1025
  if len(m.groups()) == 3:
1024
1026
  v = ver_tuple(m.group(3))
1025
1027
  if m.group(2) == ">":
1026
- v = (v[0] + 1,)
1028
+ v = v[:-1] + (v[-1] + 1,)
1027
1029
  else:
1028
1030
  v = ver_tuple(m.group(2))
1029
1031
  b = BROWSER_ALIAS[m.group(1)]
@@ -1574,7 +1576,7 @@ def to_jsonable(o):
1574
1576
  raise TypeError(type(o))
1575
1577
 
1576
1578
 
1577
- def main(argv=None):
1579
+ def _main(argv=None):
1578
1580
  ap = argparse.ArgumentParser(description="Size a JS bundle from webpack stats, an esbuild metafile or rollup-plugin-visualizer raw data, and read the project files around it.")
1579
1581
  ap.add_argument("stats", nargs="?", help="stats file, or - for stdin")
1580
1582
  ap.add_argument("--compare", nargs=2, metavar=("BEFORE", "AFTER"), help="diff two stats files")
@@ -1629,5 +1631,15 @@ def main(argv=None):
1629
1631
  return 1 if over else 0
1630
1632
 
1631
1633
 
1634
+
1635
+ def main(argv=None):
1636
+ """Run, and turn an input that parses but has the wrong shape into one clear error."""
1637
+ try:
1638
+ return _main(argv)
1639
+ except (KeyError, TypeError, AttributeError, IndexError, ValueError) as e:
1640
+ print("error: couldn't read the input as webpack, esbuild or Vite stats (%s: %s). If it is one, this is a bug: "
1641
+ "please report it with the file." % (type(e).__name__, e), file=sys.stderr)
1642
+ return 2
1643
+
1632
1644
  if __name__ == "__main__":
1633
1645
  sys.exit(main())
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "web-performance-review",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Read a Lighthouse report or PageSpeed Insights result, with field data when there is any (CrUX, PSI loadingExperience or RUM p75), and say what makes the page slow for real users and what to fix first, ranked by how much each fix moves LCP, INP and CLS at the 75th percentile. Names the LCP element and which LCP phase dominates, the elements that shift and why, the long tasks and third parties by main-thread time, and treats estimated savings as lab estimates that overlap. Use when the user shares a Lighthouse JSON or PSI result and asks \"why is this page slow\", \"what should we fix first\", \"we score 92 but Search Console says LCP is poor\", \"did our change fix INP\", \"review this Lighthouse report\", or \"how do we pass Core Web Vitals\".",
5
5
  "entrypoint": "SKILL.md",
6
6
  "deps": {
@@ -107,9 +107,11 @@ def die(msg):
107
107
 
108
108
  def load(path):
109
109
  try:
110
- raw = sys.stdin.read() if path == "-" else open(path, encoding="utf-8").read()
110
+ raw = sys.stdin.read() if path == "-" else open(path, encoding="utf-8-sig").read()
111
111
  except OSError as exc:
112
112
  die(f"cannot read {path}: {exc.strerror}")
113
+ except UnicodeDecodeError:
114
+ die(f"{path} is not UTF-8 text; save it as UTF-8 and retry")
113
115
  try:
114
116
  return json.loads(raw)
115
117
  except json.JSONDecodeError as exc:
@@ -131,6 +133,9 @@ def classify(doc, path):
131
133
  lhr = doc.get("lighthouseResult")
132
134
  if lhr is not None and not isinstance(lhr, dict):
133
135
  die(f"{path}: lighthouseResult is not an object")
136
+ if lhr and lhr.get("runtimeError"):
137
+ err = lhr["runtimeError"]
138
+ die(f"{path}: Lighthouse run failed ({err.get('code')}: {err.get('message')})")
134
139
  return lhr, fields
135
140
  if "record" in doc and isinstance(doc["record"], dict):
136
141
  f = crux_field(doc["record"])
@@ -697,7 +702,7 @@ def cmp_md(c):
697
702
  return "\n".join(L)
698
703
 
699
704
 
700
- def main():
705
+ def _main():
701
706
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
702
707
  ap.add_argument("reports", nargs="+", help="Lighthouse JSON, PSI JSON or CrUX JSON path, or - for stdin (max 2)")
703
708
  ap.add_argument("--field", help="extra field data: a PSI response or CrUX API record JSON")
@@ -739,5 +744,15 @@ def main():
739
744
  print(text)
740
745
 
741
746
 
747
+
748
+ def main():
749
+ """Run, and turn an input that parses but has the wrong shape into one clear error."""
750
+ try:
751
+ return _main()
752
+ except (KeyError, TypeError, AttributeError, IndexError, ValueError) as e:
753
+ print("error: couldn't read the input as a Lighthouse, PageSpeed Insights or CrUX report (%s: %s). If it is one, this is a bug: "
754
+ "please report it with the file." % (type(e).__name__, e), file=sys.stderr)
755
+ return 2
756
+
742
757
  if __name__ == "__main__":
743
- main()
758
+ sys.exit(main())
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "risk-register",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Build or update an enterprise or project risk register — each risk written as cause, event and consequence, scored for likelihood and impact on a defined scale, inherent and residual ratings, a named owner, a treatment (avoid, reduce, transfer, accept), controls, a due date and a review trigger — with a heat map, the top risks that need a decision, and a stdlib script that validates the register CSV and flags overdue reviews. Use when the user wants a risk register, a risk log, a RAID log's risk section, a heat map, or says \"what are our top risks\", \"update the risk register\", \"score these risks\".",
5
5
  "entrypoint": "SKILL.md",
6
6
  "deps": {
@@ -144,14 +144,24 @@ def main(argv=None):
144
144
  try:
145
145
  if args.csv == "-":
146
146
  text = sys.stdin.read()
147
+ if text.startswith("\ufeff"): # Excel's "CSV UTF-8" BOM, as the file path strips it
148
+ text = text[1:]
147
149
  else:
148
150
  with io.open(args.csv, "r", encoding="utf-8-sig", newline="") as fh:
149
151
  text = fh.read()
150
152
  except (IOError, OSError) as exc:
151
153
  print("error: cannot read %s: %s" % (args.csv, exc), file=sys.stderr)
152
154
  return 2
155
+ except UnicodeDecodeError:
156
+ print("error: %s is not UTF-8 text; save it as UTF-8 (in Excel: CSV UTF-8)" % args.csv, file=sys.stderr)
157
+ return 2
153
158
 
154
- reader = csv.DictReader(io.StringIO(text))
159
+ try: # parse once up front, so a malformed file is one clear error, not a traceback mid-loop
160
+ list(csv.reader(io.StringIO(text, newline="")))
161
+ except csv.Error as exc:
162
+ print("error: %s is not a readable CSV: %s" % (args.csv, exc), file=sys.stderr)
163
+ return 2
164
+ reader = csv.DictReader(io.StringIO(text, newline=""))
155
165
  if not reader.fieldnames:
156
166
  print("error: %s has no header row" % args.csv, file=sys.stderr)
157
167
  return 2
@@ -169,7 +179,10 @@ def main(argv=None):
169
179
  seen_ids = {}
170
180
  total_rows = 0
171
181
 
172
- for line_no, raw in enumerate(reader, start=2):
182
+ for raw in reader:
183
+ # reader.line_num counts blank lines and ends on this record's last line; step back over
184
+ # newlines inside quoted cells to the line the record starts on.
185
+ line_no = reader.line_num - sum(v.count("\n") for v in raw.values() if isinstance(v, str))
173
186
  row = dict((k.strip(), (v or "").strip()) for k, v in raw.items() if k is not None)
174
187
  if not any(row.values()):
175
188
  continue
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "terraform-plan-review",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Review a terraform plan before apply, from the plan text or terraform show -json output. Flags every destroy and replace with the attribute forcing it, changes to stateful resources such as databases, buckets and volumes, IAM and security-group widening, drift nobody made in code, and changes outside the intended scope, then gives a SAFE TO APPLY, APPLY WITH CARE or DO NOT APPLY verdict with reasons. Use when the user pastes a plan, says \"review this terraform plan\", \"is this plan safe to apply\", \"why is terraform replacing my database\", or \"check the plan before I apply\".",
5
5
  "entrypoint": "SKILL.md",
6
6
  "deps": {
@@ -66,9 +66,11 @@ def die(msg):
66
66
 
67
67
  def load_plan(path):
68
68
  try:
69
- raw = sys.stdin.read() if path == "-" else open(path, encoding="utf-8").read()
69
+ raw = sys.stdin.read() if path == "-" else open(path, encoding="utf-8-sig").read()
70
70
  except OSError as e:
71
71
  die(f"cannot read {path}: {e.strerror}")
72
+ except UnicodeDecodeError:
73
+ die(f"{path} is not UTF-8 text; save it as UTF-8 (e.g. terraform show -json tfplan | Out-File -Encoding utf8 plan.json) and retry")
72
74
  stripped = raw.lstrip()
73
75
  if not stripped:
74
76
  die("input is empty")
@@ -336,7 +338,7 @@ def to_markdown(r):
336
338
  return "\n".join(lines) + "\n"
337
339
 
338
340
 
339
- def main():
341
+ def _main():
340
342
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
341
343
  ap.add_argument("plan", help="path to `terraform show -json` output, or - for stdin")
342
344
  ap.add_argument("--scope", action="append", default=[],
@@ -357,5 +359,15 @@ def main():
357
359
  sys.stdout.write(text)
358
360
 
359
361
 
362
+
363
+ def main():
364
+ """Run, and turn an input that parses but has the wrong shape into one clear error."""
365
+ try:
366
+ return _main()
367
+ except (KeyError, TypeError, AttributeError, IndexError, ValueError) as e:
368
+ print("error: couldn't read the input as a Terraform JSON plan (%s: %s). If it is one, this is a bug: "
369
+ "please report it with the file." % (type(e).__name__, e), file=sys.stderr)
370
+ return 2
371
+
360
372
  if __name__ == "__main__":
361
- main()
373
+ sys.exit(main())
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feedback-synthesis",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Turn raw customer feedback — interview notes, support tickets, NPS or CSAT verbatims, app-store reviews, sales-call notes — into ranked themes a product decision can rest on, each a problem statement rather than a feature request, counted by distinct people or accounts not mentions, with segments, severity kept separate from frequency, verbatim quotes with source IDs, what people value, where sources disagree, what the sample cannot tell, and the questions for the next interviews. A stdlib script computes the counts. Use when the user says \"what are customers telling us\", \"synthesise this feedback\", \"find the themes in these tickets / reviews / NPS comments\", \"affinity map these interview notes\", or \"what should we fix first based on feedback\".",
5
5
  "entrypoint": "SKILL.md",
6
6
  "deps": {
@@ -75,6 +75,8 @@ def load(path: Path, person_col: str, ignore: set, segments_wanted: list):
75
75
  text = path.read_text(encoding="utf-8-sig")
76
76
  except OSError as e:
77
77
  die(f"cannot read {path}: {e}")
78
+ except UnicodeDecodeError:
79
+ die(f"{path} is not UTF-8 text; save it as UTF-8 (in Excel: CSV UTF-8)")
78
80
  reader = csv.DictReader(text.splitlines())
79
81
  if not reader.fieldnames:
80
82
  die(f"{path} has no header row")
@@ -334,7 +336,10 @@ def main(argv=None) -> int:
334
336
  a = ap.parse_args(argv)
335
337
 
336
338
  ignore = DEFAULT_IGNORE | {c.lower() for c in a.ignore}
337
- rows, segs, warnings = load(a.csv, a.person_col, ignore, a.segment)
339
+ try:
340
+ rows, segs, warnings = load(a.csv, a.person_col, ignore, a.segment)
341
+ except csv.Error as e: # e.g. a NUL byte, which Python before 3.11 rejects
342
+ die(f"{a.csv} is not a readable CSV: {e}")
338
343
  if not rows:
339
344
  die(f"{a.csv} has no feedback rows")
340
345
  label = a.person_label or ("person" if a.person_col == "person_id" else a.person_col.replace("_id", "").replace("_", " "))
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lean-canvas",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Turn a startup or internal-product idea into a lean canvas that finds what could kill it — the nine boxes filled only from what the user said, each entry tagged evidence-backed (with its source) or assumption, the assumptions ranked by how likely each is wrong times how badly the idea fails if it is, the cheapest test with a pass/fail line set in advance for the top ones, a computed unit-economics check when price and costs are given, and a plain call when the idea should change before anything is built. Use when the user says \"lean canvas\", \"help me think through my startup idea\", \"what are the riskiest assumptions\", \"how do I validate this idea\", \"is this idea worth building\", or \"poke holes in my business model\".",
5
5
  "entrypoint": "SKILL.md",
6
6
  "deps": { "npm": [], "pip": [] },
@@ -155,7 +155,7 @@ def compute(inp: dict) -> tuple[list[str], list[str]]:
155
155
  f"Break-even needs {be_whole:,} active customers but the stated market holds {int(market):,}."
156
156
  )
157
157
  else:
158
- share = be_whole / market * 100
158
+ share = be_whole / market * 100 if market else 0.0 # 0 needed of a 0 market
159
159
  lines.append(f"Break-even share of the stated market: {share:.1f}%")
160
160
  if share >= 25:
161
161
  flags.append(
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hypothesis-comparison",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Compare rival explanations for an observation with analysis of competing hypotheses (ACH) — list the hypotheses and the evidence, build a consistency matrix (consistent / inconsistent / neutral), rank hypotheses by how little evidence contradicts them rather than how much supports them, name the diagnostic evidence and the linchpins, and say which next observation would tell the leaders apart. Use when the user asks \"why did X happen\", \"which explanation fits the evidence\", \"what's really causing this\", wants to stop arguing over a favourite theory, or says \"compare these hypotheses\", \"competing explanations\", \"ACH\".",
5
5
  "entrypoint": "SKILL.md",
6
6
  "deps": {
@@ -33,6 +33,7 @@ Exit codes: 0 report written, 2 bad input.
33
33
 
34
34
  import argparse
35
35
  import csv
36
+ import io
36
37
  import sys
37
38
  from pathlib import Path
38
39
 
@@ -53,7 +54,11 @@ def load(path):
53
54
  p = Path(path)
54
55
  if not p.is_file():
55
56
  fail("file not found: " + str(path))
56
- with p.open(newline="", encoding="utf-8-sig") as f:
57
+ try:
58
+ text = p.read_text(encoding="utf-8-sig")
59
+ except UnicodeDecodeError:
60
+ fail("{} is not UTF-8 text; save it as UTF-8 (in Excel: CSV UTF-8)".format(path))
61
+ with io.StringIO(text, newline="") as f:
57
62
  reader = csv.DictReader(f)
58
63
  if not reader.fieldnames:
59
64
  fail("the CSV is empty")
@@ -66,6 +71,9 @@ def load(path):
66
71
  fail("the CSV needs at least two hypothesis columns; ACH compares rivals")
67
72
  rows = []
68
73
  for i, raw in enumerate(reader, start=1):
74
+ if None in raw:
75
+ fail("row {}: more cells than the header has columns; put double quotes around any "
76
+ "cell that contains a comma".format(i + 1))
69
77
  row = {(k or "").strip(): (v or "").strip() for k, v in raw.items()}
70
78
  lrow = {k.lower(): v for k, v in row.items()}
71
79
  ev = lrow.get("evidence", "")
@@ -120,7 +128,10 @@ def main():
120
128
  ap.add_argument("-o", "--output", help="write the report here instead of stdout")
121
129
  args = ap.parse_args()
122
130
 
123
- hyps, rows = load(args.matrix)
131
+ try:
132
+ hyps, rows = load(args.matrix)
133
+ except csv.Error as e: # e.g. a NUL byte, which Python before 3.11 rejects
134
+ fail("{} is not a readable CSV: {}".format(args.matrix, e))
124
135
  s = scores(hyps, rows)
125
136
  order = ranking(hyps, s)
126
137
  top = leaders(hyps, s)
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "source-synthesis",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Answer a question from a set of sources the user supplied — papers, reports, articles, interview notes, emails — saying only what the material supports, labelling anything that comes from elsewhere where it appears, counting a source that repeats another once, and giving a labelled estimate when the user needs a figure the sources don't settle. Written in the length and format the user asked for; a full cited literature review only when that is the ask. Use when the user hands over several sources and asks to \"synthesise these\", \"what do these papers say\", \"pull together what we know\", \"literature review\", or \"where do the sources agree and disagree\".",
5
5
  "entrypoint": "SKILL.md",
6
6
  "deps": {
@@ -44,6 +44,7 @@ SOURCE_LINE = re.compile(r"^\s*(?:[-*+]\s+)?\[?(S\d+)\]?\s*[:.)\]\-—–]\s*(\S
44
44
  BRACKET = re.compile(r"\[([^\[\]]+)\](?!\()")
45
45
  CITE_PART = re.compile(r"^\s*(S\d+)\s*(?::\s*(.*?))?\s*$")
46
46
  LOOKS_LIKE_CITE = re.compile(r"^\s*S\d+\b")
47
+ COMMA_CITE = re.compile(r",\s*S\d+\s*(?::|,|$)") # [S1:p4, S2:p7]: parts joined by a comma, not ";"
47
48
  OUTSIDE = re.compile(r"\[outside\]", re.IGNORECASE)
48
49
  NO_CITE = "<!-- no-cite -->"
49
50
  HEADING = re.compile(r"^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$")
@@ -218,7 +219,7 @@ def main():
218
219
  continue
219
220
  for part in inner.split(";"):
220
221
  pm = CITE_PART.match(part)
221
- if not pm:
222
+ if not pm or COMMA_CITE.search(pm.group(2) or ""):
222
223
  malformed.append((n, "[" + inner + "]"))
223
224
  continue
224
225
  sid, loc = pm.group(1), (pm.group(2) or "").strip()
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skill-review",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Audit an existing Agent Skill folder (SKILL.md plus its scripts, references and evals) for Claude Code, Codex, Copilot, Cursor, Kiro or Antigravity, and return a READY / FIX FIRST / REWRITE verdict with severity-tagged findings across five areas — whether a router would pick it and whether it collides with a sibling, portability, safety (mapped to the OWASP Agentic Skills Top 10), structure, and eval coverage — backed by a stdlib lint script for the mechanical checks. Use when the user says \"review this skill\", \"is my SKILL.md any good\", \"why doesn't my skill trigger\", \"is this skill safe to install\", or wants a skill checked before sharing or publishing it.",
5
5
  "entrypoint": "SKILL.md",
6
6
  "deps": {
@@ -263,7 +263,7 @@ def read_lines(path):
263
263
 
264
264
 
265
265
  def check_structure(rep, skill_dir, text):
266
- n = text.count("\n") + 1
266
+ n = text.count("\n") + (1 if text and not text.endswith("\n") else 0)
267
267
  rep.facts["skill_md_lines"] = n
268
268
  if n > MAX_LINES:
269
269
  rep.add("WARN", "structure", "SKILL.md is %d lines; past %d, split material into reference files" % (n, MAX_LINES), "SKILL.md")
@@ -322,6 +322,9 @@ def check_evals(rep, skill_dir, name):
322
322
  if not isinstance(e, dict) or not e.get("prompt"):
323
323
  rep.add("ERROR", "evals", "eval #%d has no prompt" % i, "evals/evals.json")
324
324
  continue
325
+ if not isinstance(e["prompt"], str):
326
+ rep.add("ERROR", "evals", "eval #%d prompt is not a string; write it as the text a user would type" % i, "evals/evals.json")
327
+ continue
325
328
  if not isinstance(e.get("assertions"), list) or not e["assertions"]:
326
329
  rep.add("ERROR", "evals", "eval #%d has no assertions; nothing says what a passing output looks like" % i, "evals/evals.json")
327
330
  continue
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "event-contract-draft",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Draft the contract for an event or message interface (Kafka topics, SNS/SQS, EventBridge, Pub/Sub, RabbitMQ, webhooks) as an AsyncAPI 3.0 document plus a short decisions page — past-tense event names, a required envelope with event id for deduplication, the partition key and the ordering it does and doesn't give, at-least-once delivery and how consumers stay idempotent, data minimisation with PII marked, additive-only versioning with dated deprecation, and retention, replay and dead-letter handling. Use when the user wants to define events, design a Kafka topic or message schema, write an AsyncAPI spec, agree what a producer publishes and what consumers must do, or version an event safely.",
5
5
  "entrypoint": "SKILL.md",
6
6
  "deps": { "npm": [], "pip": [] },