skilldrop-cli 0.16.42 → 0.16.44
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.
- package/package.json +1 -1
- package/packs/ai-engineering/skills/agent-config-review/manifest.json +1 -1
- package/packs/ai-engineering/skills/agent-config-review/scripts/agent_config_scan.py +6 -1
- package/packs/ai-engineering/skills/ai-usage-report/manifest.json +1 -1
- package/packs/ai-engineering/skills/ai-usage-report/scripts/build_report.py +30 -13
- package/packs/career/skills/cover-letter/manifest.json +1 -1
- package/packs/career/skills/cover-letter/scripts/claim_check.py +25 -3
- package/packs/career/skills/resume-tailor/manifest.json +1 -1
- package/packs/career/skills/resume-tailor/scripts/claim_check.py +25 -3
- package/packs/converters/skills/file-to-markdown/manifest.json +1 -1
- package/packs/converters/skills/file-to-markdown/scripts/to_markdown.py +4 -2
- package/packs/converters/skills/md-to-confluence/manifest.json +1 -1
- package/packs/converters/skills/md-to-confluence/scripts/md_to_confluence.py +8 -5
- package/packs/converters/skills/md-to-html/manifest.json +1 -1
- package/packs/converters/skills/md-to-html/scripts/md_to_html.py +7 -0
- package/packs/converters/skills/md-to-pptx/manifest.json +1 -1
- package/packs/converters/skills/md-to-pptx/scripts/md_to_pptx.py +5 -4
- package/packs/converters/skills/mermaid-render/manifest.json +1 -1
- package/packs/converters/skills/mermaid-render/scripts/render_mermaid.py +2 -1
- package/packs/core/skills/output-hygiene/manifest.json +1 -1
- package/packs/core/skills/output-hygiene/scripts/scrub.py +3 -2
- package/packs/data-analytics/skills/sql-review/manifest.json +1 -1
- package/packs/data-analytics/skills/sql-review/scripts/sql_lint.py +10 -3
- package/packs/design/skills/brand-kit/manifest.json +1 -1
- package/packs/design/skills/brand-kit/scripts/check_brand.py +11 -5
- package/packs/design/skills/deck-builder/manifest.json +1 -1
- package/packs/design/skills/deck-builder/scripts/build_deck.py +19 -9
- package/packs/design/skills/marketing-flyer/manifest.json +1 -1
- package/packs/design/skills/marketing-flyer/scripts/build_flyer.py +10 -1
- package/packs/dev-team/skills/behaviour-equivalence/manifest.json +1 -1
- package/packs/dev-team/skills/behaviour-equivalence/scripts/golden_diff.py +3 -1
- package/packs/dev-team/skills/codebase-tour/manifest.json +1 -1
- package/packs/dev-team/skills/codebase-tour/scripts/repo_facts.py +6 -1
- package/packs/dev-team/skills/dependency-upgrade-review/manifest.json +1 -1
- package/packs/dev-team/skills/dependency-upgrade-review/scripts/upgrade_diff.py +2 -2
- package/packs/dev-team/skills/pre-merge-review/manifest.json +1 -1
- package/packs/dev-team/skills/pre-merge-review/scripts/gate.py +7 -1
- package/packs/dev-team/skills/rollout-plan/manifest.json +1 -1
- package/packs/dev-team/skills/rollout-plan/scripts/canary_math.py +2 -2
- package/packs/frontend/skills/bundle-size-review/manifest.json +1 -1
- package/packs/frontend/skills/bundle-size-review/scripts/bundle_stats.py +1 -1
- package/packs/frontend/skills/web-performance-review/manifest.json +1 -1
- package/packs/frontend/skills/web-performance-review/scripts/lh_summary.py +6 -1
- package/packs/grc/skills/risk-register/manifest.json +1 -1
- package/packs/grc/skills/risk-register/scripts/check_register.py +6 -1
- package/packs/infra-as-code/skills/terraform-plan-review/manifest.json +1 -1
- package/packs/infra-as-code/skills/terraform-plan-review/scripts/plan_summary.py +3 -1
- package/packs/product-manager/skills/lean-canvas/manifest.json +1 -1
- package/packs/product-manager/skills/lean-canvas/scripts/unit_economics.py +1 -1
- package/packs/research/skills/hypothesis-comparison/manifest.json +1 -1
- package/packs/research/skills/hypothesis-comparison/scripts/ach_matrix.py +3 -0
- package/packs/research/skills/source-synthesis/manifest.json +1 -1
- package/packs/research/skills/source-synthesis/scripts/citations.py +2 -1
- package/packs/skill-engineering/skills/skill-review/manifest.json +1 -1
- package/packs/skill-engineering/skills/skill-review/scripts/lint_skill.py +4 -1
- package/packs/solution-architect/skills/event-contract-draft/manifest.json +1 -1
- package/packs/solution-architect/skills/event-contract-draft/scripts/check_asyncapi.py +9 -2
- package/packs/solution-architect/skills/figma-diagrams/manifest.json +1 -1
- package/packs/solution-architect/skills/figma-diagrams/scripts/frame_to_mermaid.py +1 -1
- package/packs/solution-architect/skills/figma-diagrams/scripts/post_comment.py +1 -1
- package/packs/solution-architect/skills/interactive-diagram/manifest.json +1 -1
- package/packs/solution-architect/skills/interactive-diagram/scripts/build_diagram.py +17 -3
- package/packs/sre-oncall/skills/cloud-cost-review/manifest.json +1 -1
- package/packs/sre-oncall/skills/cloud-cost-review/scripts/cost_summary.py +5 -2
- package/packs/sre-oncall/skills/delivery-metrics-report/manifest.json +1 -1
- package/packs/sre-oncall/skills/delivery-metrics-report/scripts/delivery_metrics.py +4 -0
- package/packs/trackers/skills/backlog-triage/manifest.json +1 -1
- package/packs/trackers/skills/backlog-triage/scripts/triage_backlog.py +7 -2
- package/packs/trackers/skills/team-status-report/manifest.json +1 -1
- package/packs/trackers/skills/team-status-report/scripts/status_counts.py +5 -1
- package/packs/trackers/skills/tracker-brief-sync/manifest.json +1 -1
- package/packs/trackers/skills/tracker-brief-sync/scripts/brief_sync.py +10 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "skilldrop-cli",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.44",
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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=
|
|
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,
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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 (& — —) outside the stashed pieces, then escape
|
|
210
210
|
# everything once. Only semicolon-terminated ones, so "?a=1¶=2" stays as typed.
|
|
211
211
|
parts = re.split("(%s\\d+%s)" % (STASH_OPEN, STASH_CLOSE), text)
|
|
212
|
-
|
|
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.
|
|
3
|
+
"version": "0.1.2",
|
|
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>")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "md-to-pptx",
|
|
3
|
-
"version": "0.1.
|
|
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
|
-
|
|
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
|
-
|
|
721
|
-
|
|
722
|
-
cur =
|
|
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
|
+
"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
|
-
|
|
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.
|
|
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
|
-
|
|
214
|
-
|
|
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.
|
|
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
|
|
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`\".]
|
|
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(
|
|
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.
|
|
3
|
+
"version": "0.1.2",
|
|
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": {
|
|
@@ -42,10 +42,16 @@ def main(argv):
|
|
|
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")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "deck-builder",
|
|
3
|
-
"version": "0.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": {
|
|
@@ -34,13 +34,17 @@ import zipfile
|
|
|
34
34
|
from dataclasses import dataclass
|
|
35
35
|
from pathlib import Path
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
from pptx
|
|
39
|
-
from pptx.
|
|
40
|
-
from pptx.
|
|
41
|
-
from pptx.enum.
|
|
42
|
-
from pptx.enum.
|
|
43
|
-
from pptx.
|
|
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 = (
|
|
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
|
-
|
|
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.
|
|
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": {
|