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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skilldrop-cli",
3
- "version": "0.16.43",
3
+ "version": "0.16.45",
4
4
  "description": "Portable AI-agent skills, measured against the agent without them. ADRs, PRDs, runbooks, decks and reviews, installable into Claude Code, Cursor, Kiro, and any IDE that takes custom instructions. One command per skill or per role-based pack.",
5
5
  "author": {
6
6
  "name": "Sanjay Ananthanarayan",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-config-review",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Review a coding agent's configuration for access broader than the work needs and give a verdict — SAFE AS CONFIGURED, TIGHTEN BEFORE USE or DO NOT USE — reading what actually wins across every scope (Claude Code user, project, local and managed settings; permission allow/deny/ask lists, defaultMode, sandbox, hooks, plugins, env; .mcp.json; subagent tool lists; Codex sandbox_mode and approval_policy and the profile in use; Cursor and Copilot agent settings), with each finding's file and line, what it lets happen in plain words, and the exact replacement config. Use when the user asks \"is this Claude Code / Codex / Cursor setup safe\", \"review our .claude/settings.json\", \"audit our agent permissions\", \"is Bash(npm:*) OK in the allow list\", \"check this repo's agent config before we open it\", or wants an agent's permissions tightened before use.",
5
5
  "entrypoint": "SKILL.md",
6
6
  "deps": {
@@ -528,7 +528,12 @@ def check_server(entry, s, lines, relpath, rep):
528
528
  rep.flag("unpinned-package", where, "{} {}".format(cmd, " ".join(args)),
529
529
  "runs whatever `{}` the registry serves at start-up{}".format(pkg or "?", ", installed without asking (-y)" if "-y" in args or "--yes" in args else ""))
530
530
  if launcher in ("docker", "podman"):
531
- image = next((a for a in args if "/" in a or ":" in a and not a.startswith("-")), "")
531
+ value_flags = ("-v", "--volume", "--mount", "-e", "--env", "--env-file", "-p", "--publish", "-w", "--workdir",
532
+ "--name", "--network", "-u", "--user", "--entrypoint", "-l", "--label", "--platform", "-h",
533
+ "--hostname", "--add-host")
534
+ skip = {i + 1 for i, a in enumerate(args) if a in value_flags}
535
+ image = next((a for i, a in enumerate(args)
536
+ if i not in skip and not a.startswith("-") and ("/" in a or ":" in a)), "")
532
537
  if image and "@sha256:" not in image and (":" not in image.split("/")[-1] or image.endswith(":latest")):
533
538
  rep.flag("unpinned-image", where, image, "runs whatever image the tag points at today")
534
539
  if any(a in ("--privileged", "--network=host") or a.startswith("/:/") or a.startswith("-v/") for a in args):
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-usage-report",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Generate a per-user or team-level report on how AI tools are being used — volume, breadth, and effectiveness signals (e.g. whether AI outputs landed in shipped artifacts vs were generated and discarded). Consumes usage-event logs exported from an MCP server or other telemetry source. Use when a manager or team lead needs to understand AI adoption beyond raw counts — including spotting \"AI theater\" where people invoke AI to claim usage but don't act on the outputs.",
5
5
  "entrypoint": "SKILL.md",
6
6
  "deps": { "npm": [], "pip": [] },
@@ -15,6 +15,7 @@ from __future__ import annotations
15
15
  import argparse
16
16
  import csv
17
17
  import json
18
+ import math
18
19
  import sys
19
20
  from collections import Counter, defaultdict
20
21
  from datetime import datetime, timedelta, timezone
@@ -27,17 +28,26 @@ MIN_USERS_FOR_ROLLUP = 5
27
28
 
28
29
  def load_events(path: Path) -> list[dict]:
29
30
  suffix = path.suffix.lower()
30
- if suffix == ".csv":
31
- with path.open(newline="") as f:
32
- return list(csv.DictReader(f))
33
- if suffix in (".jsonl", ".ndjson"):
34
- events = []
35
- with path.open() as f:
36
- for line in f:
37
- s = line.strip()
38
- if s:
39
- events.append(json.loads(s))
40
- return events
31
+ try:
32
+ if suffix == ".csv":
33
+ with path.open(newline="", encoding="utf-8-sig") as f:
34
+ return list(csv.DictReader(f))
35
+ if suffix in (".jsonl", ".ndjson"):
36
+ events = []
37
+ with path.open(encoding="utf-8-sig") as f:
38
+ for n, line in enumerate(f, 1):
39
+ s = line.strip()
40
+ if s:
41
+ try:
42
+ event = json.loads(s)
43
+ except ValueError as exc:
44
+ raise SystemExit(f"error: {path.name} line {n} is not valid JSON ({exc.msg}); fix or remove that line")
45
+ if not isinstance(event, dict):
46
+ raise SystemExit(f"error: {path.name} line {n} is not a JSON object; each line must be one event object")
47
+ events.append(event)
48
+ return events
49
+ except UnicodeDecodeError:
50
+ raise SystemExit(f"error: {path.name} is not UTF-8 text; save it as UTF-8 and re-run")
41
51
  raise SystemExit(f"error: unsupported file type {suffix!r} (use .csv or .jsonl)")
42
52
 
43
53
 
@@ -259,7 +269,7 @@ def render_per_user(per_user, tool_counter, signals, ctx, sort_for_effectiveness
259
269
  team_name=ctx["team_name"],
260
270
  start_date=ctx["start"].date().isoformat(),
261
271
  end_date=ctx["end"].date().isoformat(),
262
- n_days=(ctx["end"] - ctx["start"]).days,
272
+ n_days=ctx["n_days"],
263
273
  n_users=len(per_user),
264
274
  n_invocations=total_invocations,
265
275
  n_distinct_tools=len(tool_counter),
@@ -281,7 +291,7 @@ def render_team_rollup(per_user, tool_counter, signals, ctx):
281
291
  )
282
292
 
283
293
  template = load_template("report-team-rollup.md", ctx["script_dir"])
284
- n_days = max(1, (ctx["end"] - ctx["start"]).days)
294
+ n_days = max(1, ctx["n_days"])
285
295
 
286
296
  invocations_per_user_per_day = [
287
297
  d["invocations"] / n_days for d in per_user.values()
@@ -396,6 +406,11 @@ def main(argv) -> int:
396
406
  if not events:
397
407
  sys.stderr.write("error: input file has no events\n")
398
408
  return 1
409
+ missing = [k for k in ("timestamp", "user", "tool") if not any(k in e for e in events)]
410
+ if missing:
411
+ sys.stderr.write(f"error: input is missing required field(s): {', '.join(missing)} "
412
+ f"(see templates/usage-event-schema.md)\n")
413
+ return 1
399
414
 
400
415
  signals = detect_signals(events)
401
416
  start, end = resolve_period(args.period, args.from_arg, args.to_arg)
@@ -411,6 +426,8 @@ def main(argv) -> int:
411
426
  "team_name": args.team_name,
412
427
  "start": start,
413
428
  "end": end,
429
+ # a partial day counts, so a date-only --to (pushed to 23:59:59) covers its whole day
430
+ "n_days": math.ceil((end - start).total_seconds() / 86400),
414
431
  "caveats_line": build_data_caveats_line(signals),
415
432
  "missing_section": build_missing_signals_section(signals),
416
433
  "generated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cover-letter",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Write a cover letter for one job from the person's resume and the posting: under a page, opening on the strongest evidence for the role, two or three specifics matched to what the employer asked for, and no enthusiasm, company facts or experience the person didn't supply. A script lists every figure and name in the letter that their own material doesn't contain. Works for any occupation and level. Use when the user says \"write a cover letter for this job\", \"draft a covering letter\", \"write the cover note for my application\", or needs an application email to go with a resume.",
5
5
  "entrypoint": "SKILL.md",
6
6
  "deps": {
@@ -52,6 +52,12 @@ saturday sunday full part time application applying while although though becaus
52
52
  during after before however also please thank thanks between over across within having most each every one two three
53
53
  """.split())
54
54
  DATE_LINE = re.compile(r"^\s*(?:\d{1,2}(?:st|nd|rd|th)?\s+)?[A-Z][a-z]{2,8}\.?\s+(?:\d{1,2}(?:st|nd|rd|th)?,?\s+)?\d{4}\s*$")
55
+ # Past tenses that don't end in -ed, so a sentence-opening "Wrote" reads as a verb like "Managed".
56
+ IRREGULAR_PAST = set("""
57
+ wrote rewrote built rebuilt ran drove grew won took undertook gave brought taught held upheld kept
58
+ sold spoke began chose met oversaw cut put found understood spent sent dealt bought thought sought
59
+ split withdrew became went got knew overcame made told paid stood broke
60
+ """.split())
55
61
  # The joining words allowed inside a capitalised phrase: "Head of Geography".
56
62
  JOINERS = {"of", "and", "for", "the", "in", "at", "&", "de"}
57
63
  STOP = PLAIN | set("""
@@ -165,7 +171,7 @@ def phrases(line):
165
171
  continue
166
172
  # A sentence-opening verb in front of a name ("Led AWS migration") is dropped from the run.
167
173
  if len(r) > 1 and r[0][1] and not looks_named(r[0][0]) and r[0][0].lower() not in PLAIN:
168
- r = r[1:] if r[0][0].lower().endswith(("ed", "ing")) else r
174
+ r = r[1:] if r[0][0].lower().endswith(("ed", "ing")) or r[0][0].lower() in IRREGULAR_PAST else r
169
175
  words = [w for w, _ in r]
170
176
  while words and words[-1].lower() in JOINERS:
171
177
  words.pop()
@@ -174,6 +180,20 @@ def phrases(line):
174
180
  return cleaned
175
181
 
176
182
 
183
+ def separate_names(words, src_flat):
184
+ """True when a joiner only links names the source has whole: "SQL for Northwind Traders"."""
185
+ parts, part = [], []
186
+ for w in words + ["&"]:
187
+ if w.lower() in JOINERS:
188
+ if part:
189
+ parts.append(part)
190
+ part = []
191
+ else:
192
+ part.append(w)
193
+ return len(parts) > 1 and all((len(p) > 1 or looks_named(p[0]))
194
+ and " " + " ".join(key(w) for w in p) + " " in src_flat for p in parts)
195
+
196
+
177
197
  def check(draft, sources, posting):
178
198
  src_names, src_stems, src_flat = vocabulary("\n".join(sources))
179
199
  src_numbers = numbers("\n".join(clean(s) for s in sources))
@@ -202,9 +222,11 @@ def check(draft, sources, posting):
202
222
  missing = [w for w in content if key(w) not in src_names and w.lower() not in PLAIN]
203
223
  if missing:
204
224
  add("name", " ".join(words), n, raw, all(key(w) in post_names for w in missing))
205
- elif len(content) > 1 and all(w.lower() not in PLAIN for w in content):
225
+ elif (len(content) > 1 and any(w.lower() not in PLAIN for w in content)
226
+ and all(w.lower() not in PLAIN or key(w) in src_names for w in content)):
227
+ # every word is sourced (a plain word like "Manager" only if the source has it)
206
228
  flat = " ".join(key(w) for w in words if w != "&")
207
- if " " + flat + " " not in src_flat:
229
+ if " " + flat + " " not in src_flat and not separate_names(words, src_flat):
208
230
  add("combination", " ".join(words), n, raw)
209
231
  if posting:
210
232
  for w in WORD.findall(body):
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "resume-tailor",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Tailor a resume or CV to one job posting without adding anything that isn't true: reorder, cut and reword what the person has already done so the posting's needs are answered first, keep every employer, title, date and number as it was, and report what the posting asks for that the resume cannot claim. A script lists every figure and name in the draft that the original doesn't contain. Works for any occupation and level. Use when the user says \"tailor my resume for this job\", \"adapt my CV to this posting\", \"rewrite my resume for this role\", or pastes a job description next to their resume.",
5
5
  "entrypoint": "SKILL.md",
6
6
  "deps": {
@@ -52,6 +52,12 @@ saturday sunday full part time application applying while although though becaus
52
52
  during after before however also please thank thanks between over across within having most each every one two three
53
53
  """.split())
54
54
  DATE_LINE = re.compile(r"^\s*(?:\d{1,2}(?:st|nd|rd|th)?\s+)?[A-Z][a-z]{2,8}\.?\s+(?:\d{1,2}(?:st|nd|rd|th)?,?\s+)?\d{4}\s*$")
55
+ # Past tenses that don't end in -ed, so a sentence-opening "Wrote" reads as a verb like "Managed".
56
+ IRREGULAR_PAST = set("""
57
+ wrote rewrote built rebuilt ran drove grew won took undertook gave brought taught held upheld kept
58
+ sold spoke began chose met oversaw cut put found understood spent sent dealt bought thought sought
59
+ split withdrew became went got knew overcame made told paid stood broke
60
+ """.split())
55
61
  # The joining words allowed inside a capitalised phrase: "Head of Geography".
56
62
  JOINERS = {"of", "and", "for", "the", "in", "at", "&", "de"}
57
63
  STOP = PLAIN | set("""
@@ -165,7 +171,7 @@ def phrases(line):
165
171
  continue
166
172
  # A sentence-opening verb in front of a name ("Led AWS migration") is dropped from the run.
167
173
  if len(r) > 1 and r[0][1] and not looks_named(r[0][0]) and r[0][0].lower() not in PLAIN:
168
- r = r[1:] if r[0][0].lower().endswith(("ed", "ing")) else r
174
+ r = r[1:] if r[0][0].lower().endswith(("ed", "ing")) or r[0][0].lower() in IRREGULAR_PAST else r
169
175
  words = [w for w, _ in r]
170
176
  while words and words[-1].lower() in JOINERS:
171
177
  words.pop()
@@ -174,6 +180,20 @@ def phrases(line):
174
180
  return cleaned
175
181
 
176
182
 
183
+ def separate_names(words, src_flat):
184
+ """True when a joiner only links names the source has whole: "SQL for Northwind Traders"."""
185
+ parts, part = [], []
186
+ for w in words + ["&"]:
187
+ if w.lower() in JOINERS:
188
+ if part:
189
+ parts.append(part)
190
+ part = []
191
+ else:
192
+ part.append(w)
193
+ return len(parts) > 1 and all((len(p) > 1 or looks_named(p[0]))
194
+ and " " + " ".join(key(w) for w in p) + " " in src_flat for p in parts)
195
+
196
+
177
197
  def check(draft, sources, posting):
178
198
  src_names, src_stems, src_flat = vocabulary("\n".join(sources))
179
199
  src_numbers = numbers("\n".join(clean(s) for s in sources))
@@ -202,9 +222,11 @@ def check(draft, sources, posting):
202
222
  missing = [w for w in content if key(w) not in src_names and w.lower() not in PLAIN]
203
223
  if missing:
204
224
  add("name", " ".join(words), n, raw, all(key(w) in post_names for w in missing))
205
- elif len(content) > 1 and all(w.lower() not in PLAIN for w in content):
225
+ elif (len(content) > 1 and any(w.lower() not in PLAIN for w in content)
226
+ and all(w.lower() not in PLAIN or key(w) in src_names for w in content)):
227
+ # every word is sourced (a plain word like "Manager" only if the source has it)
206
228
  flat = " ".join(key(w) for w in words if w != "&")
207
- if " " + flat + " " not in src_flat:
229
+ if " " + flat + " " not in src_flat and not separate_names(words, src_flat):
208
230
  add("combination", " ".join(words), n, raw)
209
231
  if posting:
210
232
  for w in WORD.findall(body):
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "file-to-markdown",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Convert a Word document, PowerPoint deck, Excel workbook, web page, CSV, JSON, text file, email (.eml or Outlook .msg) or PDF into clean Markdown that keeps headings, lists, tables, links, bold and italic, slide titles and speaker notes, and reports everything it dropped (images, charts, comments, formulas, tracked changes), so the content can be read, reviewed or fed to another skill. Use when the user hands over a .docx, .pptx, .xlsx, .html, .csv, .json, .eml, .msg or .pdf and says \"read this\", \"turn this into markdown\", \"extract the text\", or wants it critiqued or briefed.",
5
5
  "entrypoint": "SKILL.md",
6
6
  "deps": {
@@ -1554,9 +1554,11 @@ def _join_split_tables(pages, rep):
1554
1554
  (not a[start - 1].strip() and start - 2 >= 0 and len(_cells(a[start - 2])) == width)):
1555
1555
  start -= 1
1556
1556
  header = " ".join(a[start].split())
1557
- # the first content on the next page, past page numbers and a running header
1557
+ # the first content on the next page, past page numbers and a running header (a line
1558
+ # that repeats the table's own header is the table's, not the page's)
1558
1559
  i = 0
1559
- while i < len(b) and (not b[i].strip() or _FURNITURE.match(b[i]) or b[i].strip() in running):
1560
+ while i < len(b) and (not b[i].strip() or _FURNITURE.match(b[i]) or
1561
+ (b[i].strip() in running and " ".join(b[i].split()) != header)):
1560
1562
  i += 1
1561
1563
  if i >= len(b) or len(_cells(b[i])) != width:
1562
1564
  continue
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "md-to-confluence",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Turn a Markdown runbook, design note or guide into a Confluence page: storage-format XHTML with real code blocks, tables, tasks, info and warning panels, images as attachments and an optional contents macro, a warning for everything dropped, and an optional publish that first shows which page it will create or overwrite and at what version, refuses stale or wrong-space overwrites, and takes credentials only from the environment. Use when the user says \"put this in Confluence\", \"publish this markdown to the wiki\", \"update the Confluence page with this\", \"convert to Confluence storage format\", or names a Confluence space for a Markdown file.",
5
5
  "entrypoint": "SKILL.md",
6
6
  "deps": {
@@ -209,7 +209,9 @@ def inline(text, ctx):
209
209
  # Decode character references (&amp; &mdash; &#8212;) outside the stashed pieces, then escape
210
210
  # everything once. Only semicolon-terminated ones, so "?a=1&para=2" stays as typed.
211
211
  parts = re.split("(%s\\d+%s)" % (STASH_OPEN, STASH_CLOSE), text)
212
- text = "".join(pt if k % 2 else ENTITY_RE.sub(lambda m: html.unescape(m.group(0)), pt).translate(XML_BAD)
212
+ # The decoded characters are cleaned of what XML can't hold; the source was cleaned already, and
213
+ # cleaning it again would delete the \x01 hard-break placeholder.
214
+ text = "".join(pt if k % 2 else ENTITY_RE.sub(lambda m: html.unescape(m.group(0)).translate(XML_BAD), pt)
213
215
  for k, pt in enumerate(parts))
214
216
  text = emphasis(esc(text))
215
217
  pat = re.compile("%s(\\d+)%s" % (STASH_OPEN, STASH_CLOSE))
@@ -277,7 +279,7 @@ def code_macro(code, lang, ctx):
277
279
  "<ac:plain-text-body>%s</ac:plain-text-body></ac:structured-macro>" % (params, cdata(code)))
278
280
 
279
281
 
280
- def render_table(rows, aligns, ctx):
282
+ def render_table(rows, aligns, ctx, row_lines):
281
283
  ctx.counts["tables"] += 1
282
284
  width = len(aligns)
283
285
 
@@ -290,6 +292,7 @@ def render_table(rows, aligns, ctx):
290
292
 
291
293
  out = ["<table><tbody>"]
292
294
  for r_i, r in enumerate(rows):
295
+ ctx.line = row_lines[r_i] # warnings name the row's own line
293
296
  if len(r) != width:
294
297
  ctx.warn("table row %d has %d cells, the header has %d; padded or cut" % (r_i, len(r), width))
295
298
  r = (r + [""] * width)[:width]
@@ -356,13 +359,13 @@ def render_blocks(lines, ctx, tight=False):
356
359
  c = c.strip()
357
360
  aligns.append("center" if c.startswith(":") and c.endswith(":") else
358
361
  "right" if c.endswith(":") else "left" if c.startswith(":") else None)
359
- rows = [split_row(line)]
362
+ rows, row_lines = [split_row(line)], [ctx.line]
360
363
  i += 2
361
364
  while i < n and lines[i].strip() and "|" in lines[i]:
362
- ctx.line = getattr(lines[i], "ln", 0)
363
365
  rows.append(split_row(lines[i]))
366
+ row_lines.append(getattr(lines[i], "ln", 0))
364
367
  i += 1
365
- out.append(render_table(rows, aligns, ctx))
368
+ out.append(render_table(rows, aligns, ctx, row_lines))
366
369
  continue
367
370
 
368
371
  if QUOTE_RE.match(line):
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "md-to-html",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Turn a Markdown draft into one self-contained, print-friendly HTML file that survives being emailed or attached, with headings that have anchor links, tables, code, quotes, an optional table of contents, local images embedded, brand colours and fonts from a brand.json, Mermaid diagrams kept as text or drawn from a pinned CDN, and raw HTML escaped so nothing in the source runs. Use when the user wants Markdown \"as a web page\", \"as an HTML file I can send\", \"something people can open in a browser and print\", or says \"convert this to HTML\".",
5
5
  "entrypoint": "SKILL.md",
6
6
  "deps": {
@@ -439,6 +439,9 @@ def load_brand(path, ctx):
439
439
  data = json.loads(p.read_text(encoding="utf-8"))
440
440
  except ValueError as e:
441
441
  sys.exit("error: brand file is not valid JSON: %s (%s)" % (path, e))
442
+ if not isinstance(data, dict):
443
+ sys.exit("error: brand file must hold a JSON object (brand-kit shape), not a %s: %s"
444
+ % (type(data).__name__, path))
442
445
  colors = data.get("colors") or {}
443
446
  for key in ("primary", "secondary", "accent", "background", "text"):
444
447
  v = colors.get(key)
@@ -525,7 +528,11 @@ def build_toc(headings):
525
528
  out = ['<nav class="toc" aria-label="Contents"><p>Contents</p><ul>']
526
529
  depth = 2
527
530
  first = True
531
+ seen_h2 = False
528
532
  for lvl, hid, txt in items:
533
+ seen_h2 = seen_h2 or lvl == 2
534
+ if not seen_h2:
535
+ lvl = 2 # a ### before any ## has no parent to nest under: list it at the top level
529
536
  link = '<a href="#%s">%s</a>' % (hid, re.sub(r"<a [^>]*>|</a>", "", txt))
530
537
  if lvl > depth:
531
538
  out.append("<ul>")
@@ -545,6 +552,9 @@ def build_toc(headings):
545
552
 
546
553
 
547
554
  def convert(src_text, base_dir, brand_path=None, toc=False, mermaid="none", allow_html=False):
555
+ # \x00 and \x01 mark the converter's own placeholders (code spans, line breaks); a source
556
+ # that contains them would collide with those markers, and neither belongs in a document.
557
+ src_text = src_text.replace("\x00", "").replace("\x01", "")
548
558
  ctx = Ctx(base_dir, allow_html, mermaid)
549
559
  brand = load_brand(brand_path, ctx)
550
560
  lines = src_text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "md-to-pptx",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Turn an existing Markdown document (a status report, runbook, review or notes) into a PowerPoint deck (.pptx) that opens without a repair prompt, with nothing to install. Each heading becomes a slide, lists keep their nesting, tables become real PowerPoint tables, images are embedded, and comments become speaker notes. A section too long for one slide is split onto \"continued\" slides instead of shrinking the text, and the reply names every split, everything dropped, and any speaker note that would go out with the file. Use when the user says \"turn this markdown into slides\", \"make this report a PowerPoint\", \"convert the .md to pptx\", \"I need this as a deck for Thursday\", or \"export these notes to PowerPoint\".",
5
5
  "entrypoint": "SKILL.md",
6
6
  "deps": {
@@ -714,12 +714,13 @@ def wrapped_lines(text, cpl):
714
714
  lines, cur = 1, 0
715
715
  for w in words:
716
716
  wl = len(w)
717
- while wl > cpl: # a very long token (URL) breaks mid-word
717
+ if wl > cpl: # a very long token (URL) starts a fresh line and breaks mid-word
718
718
  if cur:
719
719
  lines += 1
720
- lines += wl // cpl - (1 if cur == 0 else 0)
721
- wl = wl % cpl
722
- cur = 0
720
+ full, rest = divmod(wl, cpl)
721
+ lines += full - 1 + (1 if rest else 0)
722
+ cur = rest if rest else cpl
723
+ continue
723
724
  need = wl + (1 if cur else 0)
724
725
  if cur + need > cpl:
725
726
  lines += 1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mermaid-render",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Check Mermaid diagrams for the mistakes that stop them rendering (unknown diagram type, unbalanced brackets or quotes, unquoted parentheses in labels, the word end as a node id, missing arrows, unclosed subgraphs or alt blocks) with line numbers and a fix for each, then render them to SVG, PNG or PDF with mermaid-cli, or to a standalone HTML page when it is not installed. Works on a .mmd file or every mermaid fence in a Markdown doc. Use when a diagram \"won't render\", the user says \"check my mermaid\", \"export this diagram as SVG/PNG\", or needs the diagrams in a doc as image files.",
5
5
  "entrypoint": "SKILL.md",
6
6
  "deps": {
@@ -188,7 +188,8 @@ def lint_flowchart(lines, issues):
188
188
  elif re.match(r'^"[^"]*"$', rest):
189
189
  pass
190
190
  elif re.match(r"^[^\W][\w.-]*\s*[\[({>]", rest, re.U):
191
- scan_nodes(rest, ln, issues) # subgraph id[label]
191
+ # subgraph id[label], or id [label] as the Mermaid docs write it
192
+ scan_nodes(re.sub(r"^([^\W][\w.-]*)\s+", r"\1", rest, flags=re.U), ln, issues)
192
193
  elif re.search(r"[\[\](){}]", rest):
193
194
  issues.append(Issue(ln, "error", "subgraph title has unquoted brackets; write "
194
195
  "subgraph id[\"%s\"]" % rest.replace('"', "'")))
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "output-hygiene",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Review agent-written text before it ships and strip the machine artifacts it carries — invisible Unicode, non-breaking spaces, homoglyphs, harness-added provenance trailers, trailing chat closers — then flag the prose tells a script cannot catch, each quoted with a rewrite. Use when the user wants to clean up AI-generated output, asks to remove a Claude watermark, attribution footer, or \"Generated with\" line, wants a draft that does not read as machine-written, or needs a pre-ship hygiene pass on a document, commit message, or pasted chat answer.",
5
5
  "entrypoint": "SKILL.md",
6
6
  "deps": {
@@ -210,8 +210,9 @@ def scan(text, ascii_mode=False, medium="markdown"):
210
210
  blocks = re.split(r"\n\s*\n", trimmed)
211
211
  if len(blocks) > 1 and CLOSER_OPENERS.match(blocks[-1].strip()):
212
212
  tail = blocks[-1]
213
- idx = trimmed.rfind(tail)
214
- findings.append(_finding("closer", "auto", trimmed.count("\n", 0, max(idx, 0)) + 1, 1,
213
+ # Findings describe the ORIGINAL text, so locate the closer there, not in `cleaned`.
214
+ idx = text.rfind(tail.lstrip("\n").split("\n")[0])
215
+ findings.append(_finding("closer", "auto", text.count("\n", 0, max(idx, 0)) + 1, 1,
215
216
  "trailing chat closer — addresses a chat partner, not a reader",
216
217
  " ".join(tail.split())[:80]))
217
218
  cleaned = "\n\n".join(blocks[:-1]).rstrip() + ("\n" if text.endswith("\n") else "")
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sql-review",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Review a SQL query for correctness first and performance second — join fan-out inflating sums, NULL traps in filters and NOT IN, timezone and date-boundary errors, integer division, DISTINCT hiding a bad join, window-frame defaults, implicit casts, non-sargable predicates and missing partition filters — with each finding tied to a line, the wrong number it produces, and corrected SQL for the named dialect. Use when the user pastes a query and asks \"is this right\", \"review my SQL\", \"why is this number too high\", \"check this before it goes in the dashboard\", or \"make this query faster\".",
5
5
  "entrypoint": "SKILL.md",
6
6
  "deps": {
@@ -225,7 +225,11 @@ class Linter:
225
225
  if mt.group(1) == "sum" and div.group(2) == "sum":
226
226
  continue
227
227
  before = self.low[max(0, mt.start() - 15):mt.start()]
228
- if re.search(r"(1\.0\s*\*|cast\s*\(\s*|::\s*\w*\s*)$", before) or rest.startswith("::"):
228
+ if re.search(r"(1\.0\s*\*\s*|cast\s*\(\s*|::\s*\w*\s*)$", before) or rest.startswith("::"):
229
+ continue
230
+ # A cast on the denominator (count(*)::float, nullif(...)::numeric) also makes it float.
231
+ dopen = end + (div.start(1) + div.group(1).index("(") if div.group(1) else div.end() - 1)
232
+ if self.low[skip_parens(self.low, dopen):].lstrip().startswith("::"):
229
233
  continue
230
234
  sev = "warn" if (mt.group(1) == "count" and div.group(2) == "count") else "info"
231
235
  label = "COUNT / COUNT" if sev == "warn" else "SUM / COUNT"
@@ -320,12 +324,15 @@ class Linter:
320
324
  return spans
321
325
 
322
326
  def left_join_where(self) -> None:
323
- pat = re.compile(r"\bleft\s+(?:outer\s+)?join\s+[\w`\".]+\s+(?:as\s+)?(\w+)")
327
+ pat = re.compile(r"\bleft\s+(?:outer\s+)?join\s+([\w`\".]+)\s+(?:as\s+)?(\w+)")
324
328
  aliases = set()
325
329
  for mt in pat.finditer(self.low):
326
- a = mt.group(1)
330
+ a = mt.group(2)
327
331
  if a not in ("on", "using", "where"):
328
332
  aliases.add(a)
333
+ else:
334
+ # No alias: WHERE refers to the table by its own (unqualified) name.
335
+ aliases.add(mt.group(1).split(".")[-1].strip("`\""))
329
336
  if not aliases:
330
337
  return
331
338
  for start, end in self.where_clauses():
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brand-kit",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Capture a brand once as a reusable brand.json — logo files (and an on-dark version), colour roles as hex codes, heading and body fonts, voice, imagery rules, a PowerPoint template, contact details and the legal footer — plus a one-page BRAND.md, with text contrast checked and every value traced to where it came from. Use when the user wants decks, flyers or documents in their branding, says \"use our brand\", \"here's our logo and colours\", \"set up our brand kit\", or hands over brand guidelines.",
5
5
  "entrypoint": "SKILL.md",
6
6
  "deps": {
@@ -35,17 +35,23 @@ def contrast(a, b):
35
35
  return (la + 0.05) / (lb + 0.05)
36
36
 
37
37
 
38
- def main(argv):
38
+ def _main(argv):
39
39
  if len(argv) != 1:
40
40
  print(__doc__)
41
41
  return 2
42
42
  path = Path(argv[0]).expanduser().resolve()
43
43
  base = path.parent
44
44
  try:
45
- brand = json.loads(path.read_text(encoding="utf-8"))
45
+ brand = json.loads(path.read_text(encoding="utf-8-sig"))
46
46
  except (OSError, json.JSONDecodeError) as e:
47
47
  print(f"FAIL cannot read {path}: {e}")
48
48
  return 1
49
+ except UnicodeDecodeError:
50
+ print(f"FAIL cannot read {path}: it is not UTF-8 text; save it as UTF-8")
51
+ return 1
52
+ if not isinstance(brand, dict):
53
+ print(f"FAIL cannot read {path}: expected a JSON object like {{\"colors\": ...}}, got a {type(brand).__name__}")
54
+ return 1
49
55
  fails, warns = [], []
50
56
 
51
57
  colors = brand.get("colors") or {}
@@ -53,23 +59,23 @@ def main(argv):
53
59
  v = colors.get(role)
54
60
  if not v:
55
61
  warns.append(f"colors.{role} is missing; the generators will derive it from the others")
56
- elif not HEX.match(v):
62
+ elif not HEX.match(str(v)):
57
63
  fails.append(f"colors.{role} is {v!r}, not a 6-digit hex code like #1A2A6C")
58
64
  for x in colors.get("extra") or []:
59
65
  if not HEX.match(str(x.get("hex", ""))):
60
66
  fails.append(f"colors.extra {x.get('name', '?')!r} has hex {x.get('hex')!r}")
61
- if HEX.match(colors.get("text", "")) and HEX.match(colors.get("background", "")):
67
+ if HEX.match(str(colors.get("text", ""))) and HEX.match(str(colors.get("background", ""))):
62
68
  r = contrast(colors["text"], colors["background"])
63
69
  if r < 4.5:
64
70
  fails.append(f"text on background contrast is {r:.2f}:1, below 4.5:1")
65
71
  print(f" text on background {r:5.2f}:1")
66
- if HEX.match(colors.get("primary", "")):
72
+ if HEX.match(str(colors.get("primary", ""))):
67
73
  r = contrast("#FFFFFF", colors["primary"])
68
74
  msg = f"white text on primary is {r:.2f}:1"
69
75
  if r < 4.5:
70
76
  warns.append(msg + " — slide title bars and flyer bands will use dark text instead")
71
77
  print(f" white on primary {r:5.2f}:1")
72
- if HEX.match(colors.get("accent", "")) and HEX.match(colors.get("background", "")):
78
+ if HEX.match(str(colors.get("accent", ""))) and HEX.match(str(colors.get("background", ""))):
73
79
  r = contrast(colors["accent"], colors["background"])
74
80
  if r < 3:
75
81
  warns.append(f"accent on background is {r:.2f}:1 — use the accent for shapes and rules, not text")
@@ -110,5 +116,15 @@ def main(argv):
110
116
  return 1 if fails else 0
111
117
 
112
118
 
119
+
120
+ def main(argv):
121
+ """Run, and turn an input that parses but has the wrong shape into one clear error."""
122
+ try:
123
+ return _main(argv)
124
+ except (KeyError, TypeError, AttributeError, IndexError, ValueError) as e:
125
+ print("error: couldn't read the input as a brand file (%s: %s). If it is one, this is a bug: "
126
+ "please report it with the file." % (type(e).__name__, e), file=sys.stderr)
127
+ return 2
128
+
113
129
  if __name__ == "__main__":
114
130
  sys.exit(main(sys.argv[1:]))
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deck-builder",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "Generate a real PowerPoint (.pptx) file from content, audience, and either a brand template or a color palette — with charts, tables and images, not only bullets. Slide count, density and layout are tuned to the audience: execs, boards, technical reviewers, sales prospects, investors, internal teams. Use when the user wants an actual editable .pptx rather than an outline, wants their corporate template or brand colours applied, or says \"build the deck\", \"make it a pptx\", \"use our template\".",
5
5
  "entrypoint": "SKILL.md",
6
6
  "deps": {