skilldrop-cli 0.16.32 → 0.16.34
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/bin/skilldrop.js +104 -2
- package/package.json +1 -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 +2 -2
- package/packs/converters/skills/file-to-markdown/SKILL.md +8 -4
- package/packs/converters/skills/file-to-markdown/manifest.json +11 -2
- package/packs/converters/skills/file-to-markdown/scripts/to_markdown.py +118 -8
- package/packs/converters/skills/md-to-docx/examples/q3-support-review.md +7 -0
- package/packs/converters/skills/md-to-xlsx/examples/vendor-review.md +6 -1
- package/packs/design/skills/brand-kit/manifest.json +1 -1
- package/packs/design/skills/brand-kit/scripts/check_brand.py +1 -1
- package/packs/design/skills/deck-builder/manifest.json +1 -1
- package/packs/design/skills/deck-builder/scripts/build_deck.py +3 -3
- package/packs/design/skills/marketing-flyer/manifest.json +1 -1
- package/packs/design/skills/marketing-flyer/scripts/build_flyer.py +1 -1
- package/packs/dev-team/skills/pre-merge-review/manifest.json +1 -1
- package/packs/dev-team/skills/pre-merge-review/scripts/gate.py +1 -1
package/bin/skilldrop.js
CHANGED
|
@@ -37,6 +37,8 @@ const HELP = `skilldrop — portable AI-agent skills for Claude Code, Cursor, Ki
|
|
|
37
37
|
|
|
38
38
|
Usage:
|
|
39
39
|
skilldrop list [--from <src>] all skills in a catalog (name, version, tier)
|
|
40
|
+
skilldrop search <words...> [--kind skill|loop|agent|pack] [--limit N] [--json]
|
|
41
|
+
find skills, loops, agents and packs by what they're for
|
|
40
42
|
skilldrop info <skill> [--from <src>] description, related, packs, deps
|
|
41
43
|
skilldrop info --pack <name> a pack's skills, loops, and what to try first (RFC-0032)
|
|
42
44
|
skilldrop packs [--from <src>] role-based packs
|
|
@@ -149,7 +151,8 @@ function parseArgs(argv) {
|
|
|
149
151
|
for (let i = 0; i < argv.length; i++) {
|
|
150
152
|
const a = argv[i];
|
|
151
153
|
if (a === "--pack" || a === "--ide" || a === "--dest" || a === "--from" || a === "--panel" || a === "--profile" || a === "--tier" || a === "--skills" || a === "--days" ||
|
|
152
|
-
a === "--policy" || a === "--merge" || a === "--label" || a === "--last" || a === "--html"
|
|
154
|
+
a === "--policy" || a === "--merge" || a === "--label" || a === "--last" || a === "--html" ||
|
|
155
|
+
a === "--kind" || a === "--limit") out.flags[a.slice(2)] = argv[++i];
|
|
153
156
|
else if (a.startsWith("--")) out.flags[a.slice(2)] = true;
|
|
154
157
|
else out._.push(a);
|
|
155
158
|
}
|
|
@@ -2410,6 +2413,105 @@ function list(args) {
|
|
|
2410
2413
|
console.log(`\n${rows.length} skills in catalog '${cat.source}'. Details: skilldrop info <skill>`);
|
|
2411
2414
|
}
|
|
2412
2415
|
|
|
2416
|
+
/* search <words> (RFC-0056): find a skill, loop, agent or pack by what it is for, from the
|
|
2417
|
+
terminal and offline. Every word must match (by prefix, so "deploy" finds "deployment");
|
|
2418
|
+
a name match outweighs a tag, a tag a pack or outcome, and those the description or the
|
|
2419
|
+
phrasings the skill's trigger evals route to it. Filler words are dropped. When no
|
|
2420
|
+
entry matches every word, entries that match some are shown, and the reply says so. */
|
|
2421
|
+
function search(args) {
|
|
2422
|
+
// Words that say nothing about what a skill does ("why did my…") are dropped, unless they
|
|
2423
|
+
// are all there is.
|
|
2424
|
+
const STOP = new Set(("a an and are as at be but by can could did do does for from get got had has have help how i in " +
|
|
2425
|
+
"into is it its me my need of on or our please should so that the their them this to us want was we were what when " +
|
|
2426
|
+
"where which who why will with would you your").split(" "));
|
|
2427
|
+
const raw = args._.join(" ").toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 1);
|
|
2428
|
+
const words = raw.filter((w) => !STOP.has(w)).length ? raw.filter((w) => !STOP.has(w)) : raw;
|
|
2429
|
+
if (!words.length) die("usage: skilldrop search <words...> [--kind skill|loop|agent|pack] [--limit N] [--json] [--from <src>]");
|
|
2430
|
+
const cat = resolveCatalog(args.flags.from);
|
|
2431
|
+
const kinds = ["skill", "loop", "agent", "pack"];
|
|
2432
|
+
const want = args.flags.kind;
|
|
2433
|
+
if (want && !kinds.includes(want)) die(`--kind takes one of ${kinds.join(", ")}`);
|
|
2434
|
+
const limit = Math.max(1, Number(args.flags.limit) || 10);
|
|
2435
|
+
const toks = (x) => String(x || "").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
|
2436
|
+
const idx = cat.dir ? path.join(cat.dir, "catalogue.json") : null;
|
|
2437
|
+
const outcomes = idx && fs.existsSync(idx) ? (readJSON(idx).outcomes || {}) : {};
|
|
2438
|
+
const outcomeOf = {};
|
|
2439
|
+
for (const [o, d] of Object.entries(outcomes)) for (const sk of d.skills || []) (outcomeOf[sk] = outcomeOf[sk] || []).push(o);
|
|
2440
|
+
const ps = packsOf(cat) || {};
|
|
2441
|
+
const packOf = {};
|
|
2442
|
+
for (const [pn, pd] of Object.entries(ps)) for (const sk of pd.skills || []) if (!packOf[sk]) packOf[sk] = pn;
|
|
2443
|
+
const entries = [];
|
|
2444
|
+
const add = (kind, name, description, fields) => entries.push({ kind, name, description: description || "", fields });
|
|
2445
|
+
for (const sk of skillsIn(cat)) {
|
|
2446
|
+
let m = {};
|
|
2447
|
+
try { m = manifestOf(cat, sk); } catch (e) { /* listed with what we have */ }
|
|
2448
|
+
// The phrasings the skill's own trigger evals say should route to it: how people ask.
|
|
2449
|
+
let asks = [];
|
|
2450
|
+
try {
|
|
2451
|
+
const q = path.join(skillDir(cat, sk), "evals", "eval_queries.json");
|
|
2452
|
+
if (fs.existsSync(q)) asks = readJSON(q).filter((r) => r && r.should_trigger).map((r) => r.query);
|
|
2453
|
+
} catch (e) { /* no phrasings, still searchable by its description */ }
|
|
2454
|
+
add("skill", sk, m.description, { name: toks(sk), tags: toks((m.tags || []).join(" ")),
|
|
2455
|
+
group: toks([packOf[sk], ...(outcomeOf[sk] || [])].join(" ")), text: toks(m.description), asks: toks(asks.join(" ")) });
|
|
2456
|
+
}
|
|
2457
|
+
for (const l of loopsIn(cat)) {
|
|
2458
|
+
let spec = {};
|
|
2459
|
+
try { spec = loopSpec(cat, l); } catch (e) { /* skip unreadable */ }
|
|
2460
|
+
add("loop", l, spec.description, { name: toks(l), tags: toks((spec.stages || []).map((st) => st.id).join(" ")),
|
|
2461
|
+
group: toks((spec.stages || []).flatMap((st) => st.skills || []).join(" ")), text: toks(spec.description) });
|
|
2462
|
+
}
|
|
2463
|
+
for (const a of agentsIn(cat)) {
|
|
2464
|
+
let meta = {};
|
|
2465
|
+
try { meta = agentMeta(cat, a); } catch (e) { /* skip unreadable */ }
|
|
2466
|
+
add("agent", a, meta.description, { name: toks(a), tags: [], group: [], text: toks(meta.description) });
|
|
2467
|
+
}
|
|
2468
|
+
for (const [pn, pd] of Object.entries(ps))
|
|
2469
|
+
add("pack", pn, pd.description, { name: toks(pn + " " + (pd.display_name || "")), tags: toks((pd.keywords || []).join(" ")),
|
|
2470
|
+
group: toks((pd.skills || []).join(" ")), text: toks(pd.description) });
|
|
2471
|
+
const WEIGHT = { name: 6, tags: 3, group: 2, text: 1, asks: 1 };
|
|
2472
|
+
const stem = (w) => (w.length > 4 && w.endsWith("s") ? w.slice(0, -1) : w);
|
|
2473
|
+
const score = (e) => {
|
|
2474
|
+
let total = 0, hit = 0;
|
|
2475
|
+
for (const w of words.map(stem)) {
|
|
2476
|
+
let best = 0;
|
|
2477
|
+
for (const [f, list] of Object.entries(e.fields))
|
|
2478
|
+
for (const t of list)
|
|
2479
|
+
if (t === w || t.startsWith(w)) best = Math.max(best, WEIGHT[f] * (t === w ? 1.5 : 1));
|
|
2480
|
+
if (best) { total += best; hit++; }
|
|
2481
|
+
}
|
|
2482
|
+
return { total, hit };
|
|
2483
|
+
};
|
|
2484
|
+
let ranked = entries.filter((e) => !want || e.kind === want).map((e) => Object.assign(e, score(e)));
|
|
2485
|
+
const all = ranked.filter((e) => e.hit === words.length);
|
|
2486
|
+
const partial = !all.length;
|
|
2487
|
+
ranked = (partial ? ranked.filter((e) => e.hit > 0) : all)
|
|
2488
|
+
.sort((a, b) => b.hit - a.hit || b.total - a.total || a.name.localeCompare(b.name));
|
|
2489
|
+
const shown = ranked.slice(0, limit);
|
|
2490
|
+
// A retired skill searched for by name gets its note, not silence (RFC-0046).
|
|
2491
|
+
const retired = [...new Set([args._.join("-"), ...args._].map((n) => String(n).toLowerCase()))]
|
|
2492
|
+
.map((n) => retiredNote(cat, n)).filter(Boolean);
|
|
2493
|
+
if (args.flags.json)
|
|
2494
|
+
return emitJSON({ catalog: cat.source, query: words, matched: partial ? "some words" : "every word", total: ranked.length,
|
|
2495
|
+
results: shown.map((e) => ({ kind: e.kind, name: e.name, pack: e.kind === "skill" ? packOf[e.name] || null : null,
|
|
2496
|
+
description: e.description, score: Math.round(e.total * 10) / 10 })), retired });
|
|
2497
|
+
for (const r of retired) console.log(`${r}\n`);
|
|
2498
|
+
if (!ranked.length) {
|
|
2499
|
+
console.log(`Nothing in catalog '${cat.source}' matches "${words.join(" ")}". Try fewer or different words, or: skilldrop list`);
|
|
2500
|
+
return;
|
|
2501
|
+
}
|
|
2502
|
+
if (partial) console.log(`No entry matches every word of "${words.join(" ")}"; these match some:\n`);
|
|
2503
|
+
const first = (d) => { const t = d.split(" Use when")[0].replace(/\s+/g, " ").trim(); return t.length > 150 ? t.slice(0, 147).replace(/\s+\S*$/, "") + "…" : t; };
|
|
2504
|
+
for (const e of shown) {
|
|
2505
|
+
const where = e.kind === "skill" ? (packOf[e.name] ? `skill, ${packOf[e.name]} pack` : "skill") : e.kind;
|
|
2506
|
+
console.log(`${e.name} (${where})\n ${first(e.description)}`);
|
|
2507
|
+
}
|
|
2508
|
+
const more = ranked.length - shown.length;
|
|
2509
|
+
const top = shown[0];
|
|
2510
|
+
const how = { skill: `skilldrop info ${top.name} · skilldrop install ${top.name}`, loop: `skilldrop install --loop ${top.name}`,
|
|
2511
|
+
agent: `skilldrop install --agent ${top.name}`, pack: `skilldrop info --pack ${top.name} · skilldrop install --pack ${top.name}` }[top.kind];
|
|
2512
|
+
console.log(`\n${ranked.length} match${ranked.length === 1 ? "" : "es"}${more > 0 ? `, showing ${shown.length} (--limit to see more)` : ""}. Next: ${how}`);
|
|
2513
|
+
}
|
|
2514
|
+
|
|
2413
2515
|
/* RFC-0032: what to try first after installing a pack, how to tell it worked, what to do
|
|
2414
2516
|
if it didn't — the same first-value block the pack's site page renders. */
|
|
2415
2517
|
function firstValueLines(p) {
|
|
@@ -2666,7 +2768,7 @@ function bootstrap() {
|
|
|
2666
2768
|
|
|
2667
2769
|
const args = parseArgs(process.argv.slice(2));
|
|
2668
2770
|
const cmd = args._.shift();
|
|
2669
|
-
const commands = { list, info, packs: listPacks, profiles: listProfiles, agents: listAgents, loops: listLoops, install, update, outdated, uninstall, diff, doctor, "new-skill": newSkill, "loop-stats": loopStats, "init-catalogue": initCatalogue, package: packageCatalogue, validate: validateCmd, scan, bootstrap,
|
|
2771
|
+
const commands = { list, search, info, packs: listPacks, profiles: listProfiles, agents: listAgents, loops: listLoops, install, update, outdated, uninstall, diff, doctor, "new-skill": newSkill, "loop-stats": loopStats, "init-catalogue": initCatalogue, package: packageCatalogue, validate: validateCmd, scan, bootstrap,
|
|
2670
2772
|
inventory, audit: auditCmd, "verify-mirror": verifyMirrorCmd };
|
|
2671
2773
|
if (args.flags.version || cmd === "version") console.log(readJSON(path.join(ROOT, "package.json")).version);
|
|
2672
2774
|
else if (!cmd || cmd === "help" || args.flags.help) console.log(HELP);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "skilldrop-cli",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.34",
|
|
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": "ai-usage-report",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
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": [] },
|
|
@@ -212,7 +212,7 @@ def load_template(name: str, script_dir: Path) -> str:
|
|
|
212
212
|
p = script_dir.parent / "templates" / name
|
|
213
213
|
if not p.exists():
|
|
214
214
|
raise SystemExit(f"error: template not found: {p}")
|
|
215
|
-
return p.read_text()
|
|
215
|
+
return p.read_text(encoding="utf-8")
|
|
216
216
|
|
|
217
217
|
|
|
218
218
|
# --------------------------------------------------------------------- views
|
|
@@ -427,7 +427,7 @@ def main(argv) -> int:
|
|
|
427
427
|
|
|
428
428
|
out_path = Path(args.out).expanduser().resolve() if args.out else input_path.with_suffix(".report.md")
|
|
429
429
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
430
|
-
out_path.write_text(out)
|
|
430
|
+
out_path.write_text(out, encoding="utf-8")
|
|
431
431
|
print(f"wrote {out_path} ({len(events)} events, {len(per_user)} users, view={args.view})")
|
|
432
432
|
return 0
|
|
433
433
|
|
|
@@ -47,12 +47,16 @@ or a rewrite that goes back out through `md-to-docx` or `md-to-html`.
|
|
|
47
47
|
| `.csv` / `.tsv` | One table, delimiter detected |
|
|
48
48
|
| `.json` | An array of objects becomes a table; an object becomes a key/value table plus a table per array |
|
|
49
49
|
| `.txt` / `.md` | Passed through |
|
|
50
|
-
| `.pdf` | Through `pdftotext`. Tables from aligned columns, lists from indentation, short standalone lines as `###` headings (guessed, and said so); pages with side-by-side columns are read in column order with nothing inferred. No images |
|
|
50
|
+
| `.pdf` | Through `pdftotext`. Tables from aligned columns, lists from indentation, short standalone lines as `###` headings (guessed, and said so); a table that runs onto the next page is joined into one, without the header the PDF repeats; pages with side-by-side columns are read in column order with nothing inferred. Scanned pages are read by OCR when `tesseract` is installed. No images |
|
|
51
51
|
|
|
52
52
|
3. **PDF without `pdftotext`.** The script stops with install steps (`brew install poppler`
|
|
53
53
|
on macOS, `sudo apt install poppler-utils` on Debian/Ubuntu). Pass those on; don't try to
|
|
54
|
-
read the PDF bytes yourself. A
|
|
55
|
-
|
|
54
|
+
read the PDF bytes yourself. A page with no text layer is a scan. The script reads it by
|
|
55
|
+
OCR when `tesseract` is installed (`brew install tesseract`, `sudo apt install tesseract-ocr`),
|
|
56
|
+
and otherwise stops with those steps. For a scan that isn't in English, pass `--ocr-lang`
|
|
57
|
+
with tesseract's code (`deu`, `fra`, `eng+fra`). OCR misreads characters, so the reply says
|
|
58
|
+
which pages were OCR'd and asks the user to check every number, name and date in them
|
|
59
|
+
against the PDF.
|
|
56
60
|
|
|
57
61
|
4. **Old binary formats** (`.doc`, `.ppt`, `.xls`, `.rtf`, `.pages`, `.key`): the script
|
|
58
62
|
refuses and names the format to save as. On macOS, `textutil -convert docx file.doc`
|
|
@@ -73,7 +77,7 @@ missing, emit `BLOCKED: need <the file | pdftotext on PATH>` and stop.
|
|
|
73
77
|
|
|
74
78
|
## Useful references in this skill
|
|
75
79
|
|
|
76
|
-
- [`scripts/to_markdown.py`](scripts/to_markdown.py): the converter (stdlib only; `pdftotext` optional for PDF)
|
|
80
|
+
- [`scripts/to_markdown.py`](scripts/to_markdown.py): the converter (stdlib only; `pdftotext` optional for PDF, `tesseract` for scanned pages)
|
|
77
81
|
- [`examples/mixed-inputs.md`](examples/mixed-inputs.md): a Word brief, a deck, a workbook and a web page converted, with the real Markdown and stderr report
|
|
78
82
|
|
|
79
83
|
## Quality bar
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "file-to-markdown",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Convert a Word document, PowerPoint deck, Excel workbook, web page, CSV, JSON, text file 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 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": {
|
|
@@ -41,7 +41,16 @@
|
|
|
41
41
|
"pdf",
|
|
42
42
|
"extract"
|
|
43
43
|
],
|
|
44
|
-
"permissions": {
|
|
44
|
+
"permissions": {
|
|
45
|
+
"network": [],
|
|
46
|
+
"commands": [
|
|
47
|
+
"pdftotext",
|
|
48
|
+
"pdftoppm",
|
|
49
|
+
"tesseract"
|
|
50
|
+
],
|
|
51
|
+
"files": "named-paths",
|
|
52
|
+
"notes": "pdftotext (Poppler) is used only for .pdf input, and only if it is installed. A page with no text layer is rendered with pdftoppm (Poppler) into a temporary folder that is deleted afterwards, and read with tesseract, only if tesseract is installed."
|
|
53
|
+
},
|
|
45
54
|
"model": {
|
|
46
55
|
"tier": "light",
|
|
47
56
|
"rationale": "A deterministic stdlib script does the extraction; the model runs it, relays the dropped-content report and hands off. No generation beyond a short summary of losses."
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
"""Convert a document into clean Markdown that another skill can read.
|
|
3
3
|
|
|
4
4
|
Stdlib only, Python 3.9+. Handles .docx, .pptx, .xlsx, .html/.htm, .csv/.tsv,
|
|
5
|
-
.json, .txt/.md, and .pdf (through `pdftotext` when it is installed).
|
|
5
|
+
.json, .txt/.md, and .pdf (through `pdftotext` when it is installed). A PDF page with no
|
|
6
|
+
text layer (a scan) is read by OCR when `tesseract` and `pdftoppm` are installed.
|
|
6
7
|
Everything the conversion could not carry (images, charts, comments, formulas,
|
|
7
8
|
tracked changes) is counted and reported on stderr, so nobody mistakes the
|
|
8
9
|
Markdown for the whole document.
|
|
@@ -12,6 +13,7 @@ Usage:
|
|
|
12
13
|
python3 to_markdown.py deck.pptx -o deck.md
|
|
13
14
|
python3 to_markdown.py model.xlsx -o model.md --max-rows 200
|
|
14
15
|
python3 to_markdown.py page.html -o - # write to stdout
|
|
16
|
+
python3 to_markdown.py scan.pdf -o scan.md --ocr-lang deu # OCR a German scan
|
|
15
17
|
"""
|
|
16
18
|
|
|
17
19
|
import argparse
|
|
@@ -22,6 +24,7 @@ import re
|
|
|
22
24
|
import shutil
|
|
23
25
|
import subprocess
|
|
24
26
|
import sys
|
|
27
|
+
import tempfile
|
|
25
28
|
import zipfile
|
|
26
29
|
from collections import OrderedDict
|
|
27
30
|
from datetime import datetime, timedelta
|
|
@@ -1122,8 +1125,9 @@ def _pdf_page_md(page, rep):
|
|
|
1122
1125
|
region.append(line); rows += 1; cols |= _col_starts(line); j += 1
|
|
1123
1126
|
elif line.strip() and _col_starts(line) and _col_starts(line) <= _near(cols):
|
|
1124
1127
|
region.append(line); j += 1
|
|
1125
|
-
elif not line.strip() and j + 1 < n and lines[j + 1]
|
|
1126
|
-
|
|
1128
|
+
elif not line.strip() and j + 1 < n and len(_cells(lines[j + 1])) >= 2:
|
|
1129
|
+
# After a blank line only a full row continues the table. A one-cell line
|
|
1130
|
+
# there ("End of report.") is the next paragraph, not a wrapped cell.
|
|
1127
1131
|
j += 1
|
|
1128
1132
|
else:
|
|
1129
1133
|
break
|
|
@@ -1193,7 +1197,83 @@ def _pdf_page_md(page, rep):
|
|
|
1193
1197
|
return out
|
|
1194
1198
|
|
|
1195
1199
|
|
|
1196
|
-
|
|
1200
|
+
_FURNITURE = re.compile(r"^\s*(page\s+)?\d{1,4}(\s*(of|/)\s*\d{1,4})?\s*$|^\s*[-–—]\s*\d{1,4}\s*[-–—]\s*$", re.I)
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
def _join_split_tables(pages, rep):
|
|
1204
|
+
"""A table that runs off the foot of one page continues at the top of the next, where it
|
|
1205
|
+
would otherwise start a second table whose first data row reads as its header. When a page
|
|
1206
|
+
ends in a table and the next page's first rows (after page numbers and any running header)
|
|
1207
|
+
have the same number of cells, those rows are moved up to finish the table. A header row
|
|
1208
|
+
the PDF repeats on the new page is dropped. Returns the pages, rewritten."""
|
|
1209
|
+
pages = [p.split("\n") for p in pages]
|
|
1210
|
+
first_lines = [next((l.strip() for l in p if l.strip()), "") for p in pages]
|
|
1211
|
+
running = {t for t in first_lines if t and first_lines.count(t) > 1} # a header on several pages
|
|
1212
|
+
holder = None
|
|
1213
|
+
for k in range(len(pages) - 1):
|
|
1214
|
+
a_idx = k if holder is None else holder
|
|
1215
|
+
a, b = pages[a_idx], pages[k + 1]
|
|
1216
|
+
# the last table row on the holder page, before trailing page furniture
|
|
1217
|
+
end = len(a) - 1
|
|
1218
|
+
while end >= 0 and (not a[end].strip() or _FURNITURE.match(a[end])):
|
|
1219
|
+
end -= 1
|
|
1220
|
+
holder = None
|
|
1221
|
+
if end < 0 or len(_cells(a[end])) < 2:
|
|
1222
|
+
continue
|
|
1223
|
+
width = len(_cells(a[end]))
|
|
1224
|
+
start = end
|
|
1225
|
+
while start - 1 >= 0 and (len(_cells(a[start - 1])) == width or
|
|
1226
|
+
(not a[start - 1].strip() and start - 2 >= 0 and len(_cells(a[start - 2])) == width)):
|
|
1227
|
+
start -= 1
|
|
1228
|
+
header = " ".join(a[start].split())
|
|
1229
|
+
# the first content on the next page, past page numbers and a running header
|
|
1230
|
+
i = 0
|
|
1231
|
+
while i < len(b) and (not b[i].strip() or _FURNITURE.match(b[i]) or b[i].strip() in running):
|
|
1232
|
+
i += 1
|
|
1233
|
+
if i >= len(b) or len(_cells(b[i])) != width:
|
|
1234
|
+
continue
|
|
1235
|
+
j = i
|
|
1236
|
+
rows = []
|
|
1237
|
+
while j < len(b) and (len(_cells(b[j])) == width or
|
|
1238
|
+
(not b[j].strip() and j + 1 < len(b) and len(_cells(b[j + 1])) == width)):
|
|
1239
|
+
if b[j].strip():
|
|
1240
|
+
rows.append(b[j])
|
|
1241
|
+
j += 1
|
|
1242
|
+
dropped = bool(rows) and " ".join(rows[0].split()) == header
|
|
1243
|
+
if dropped:
|
|
1244
|
+
rows = rows[1:]
|
|
1245
|
+
if not rows:
|
|
1246
|
+
continue
|
|
1247
|
+
a[end + 1:end + 1] = rows
|
|
1248
|
+
del b[i:j]
|
|
1249
|
+
rep.notes.append("a table continued from page %d onto page %d was joined into one%s" % (
|
|
1250
|
+
a_idx + 1, k + 2, ", and the header row repeated on page %d was dropped" % (k + 2) if dropped else ""))
|
|
1251
|
+
rep.keep("pdf tables joined across pages", 1)
|
|
1252
|
+
if not any(l.strip() and not _FURNITURE.match(l) and l.strip() not in running for l in b):
|
|
1253
|
+
holder = a_idx # the whole page was table: the next page may continue it too
|
|
1254
|
+
return ["\n".join(p) for p in pages]
|
|
1255
|
+
|
|
1256
|
+
|
|
1257
|
+
def _ocr_page(path, n, ppm, tess, lang):
|
|
1258
|
+
"""Page n of the PDF as text, by rendering it at 300 dpi and reading it with tesseract.
|
|
1259
|
+
Page segmentation mode 6 reads a uniform block and keeps the spacing between words, which
|
|
1260
|
+
is what the table and list inference reads columns from."""
|
|
1261
|
+
with tempfile.TemporaryDirectory(prefix="to-md-ocr-") as tmp:
|
|
1262
|
+
stem = str(Path(tmp) / "page")
|
|
1263
|
+
r = subprocess.run([ppm, "-r", "300", "-png", "-f", str(n), "-l", str(n), str(path), stem],
|
|
1264
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
|
|
1265
|
+
pngs = sorted(Path(tmp).glob("page*.png"))
|
|
1266
|
+
if r.returncode != 0 or not pngs:
|
|
1267
|
+
sys.exit("error: pdftoppm could not render page %d: %s" % (n, r.stderr.decode("utf-8", "replace").strip()))
|
|
1268
|
+
r = subprocess.run([tess, str(pngs[0]), "stdout", "-l", lang, "--psm", "6",
|
|
1269
|
+
"-c", "preserve_interword_spaces=1"],
|
|
1270
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
|
|
1271
|
+
if r.returncode != 0:
|
|
1272
|
+
sys.exit("error: tesseract failed on page %d: %s" % (n, r.stderr.decode("utf-8", "replace").strip()))
|
|
1273
|
+
return r.stdout.decode("utf-8", "replace").replace("\f", "")
|
|
1274
|
+
|
|
1275
|
+
|
|
1276
|
+
def pdf_to_md(path, rep, ocr=True, ocr_lang="eng"):
|
|
1197
1277
|
exe = shutil.which("pdftotext")
|
|
1198
1278
|
if not exe:
|
|
1199
1279
|
sys.exit("error: reading PDF needs `pdftotext` (part of Poppler), which is not on PATH.\n"
|
|
@@ -1211,9 +1291,33 @@ def pdf_to_md(path, rep):
|
|
|
1211
1291
|
return pages[:-1] if pages and not pages[-1].strip() else pages
|
|
1212
1292
|
|
|
1213
1293
|
layout, reading = run(["-layout"]), run([])
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1294
|
+
blank = [n for n, p in enumerate(layout, 1) if not p.strip()]
|
|
1295
|
+
if blank and ocr is not False:
|
|
1296
|
+
tess, ppm = shutil.which("tesseract"), shutil.which("pdftoppm")
|
|
1297
|
+
if not (tess and ppm):
|
|
1298
|
+
if len(blank) == len(layout):
|
|
1299
|
+
sys.exit("error: %s has no text layer (probably scanned), so it needs OCR, which uses "
|
|
1300
|
+
"`tesseract`%s.\n"
|
|
1301
|
+
" macOS: brew install tesseract (pdftoppm comes with poppler)\n"
|
|
1302
|
+
" Debian/Ubuntu: sudo apt install tesseract-ocr poppler-utils\n"
|
|
1303
|
+
" Windows: install Tesseract (UB Mannheim build) and add it to PATH\n"
|
|
1304
|
+
"Then rerun. Add --ocr-lang deu (or another installed language) for a non-English scan."
|
|
1305
|
+
% (path, "" if ppm else " and `pdftoppm`"))
|
|
1306
|
+
rep.notes.append("page(s) %s have no text layer and were left empty: install tesseract to read them by OCR"
|
|
1307
|
+
% ", ".join(map(str, blank)))
|
|
1308
|
+
else:
|
|
1309
|
+
for n in blank:
|
|
1310
|
+
text = _ocr_page(path, n, ppm, tess, ocr_lang)
|
|
1311
|
+
layout[n - 1] = text
|
|
1312
|
+
if n <= len(reading):
|
|
1313
|
+
reading[n - 1] = text
|
|
1314
|
+
rep.keep("pages read by OCR", len(blank))
|
|
1315
|
+
rep.notes.append("page(s) %s are scanned, so their text was read by OCR (tesseract, language %s, 300 dpi). "
|
|
1316
|
+
"OCR misreads characters: check every number, name and date against the PDF"
|
|
1317
|
+
% (", ".join(map(str, blank)), ocr_lang))
|
|
1318
|
+
elif blank and len(blank) == len(layout):
|
|
1319
|
+
sys.exit("error: %s has no text layer (probably scanned), and --no-ocr was given." % path)
|
|
1320
|
+
layout = _join_split_tables(layout, rep)
|
|
1217
1321
|
out, plain_pages = [], []
|
|
1218
1322
|
for n, page in enumerate(layout, 1):
|
|
1219
1323
|
out += ["<!-- page %d -->" % n, ""]
|
|
@@ -1250,7 +1354,12 @@ def main(argv=None):
|
|
|
1250
1354
|
ap.add_argument("-o", "--output", required=True, help="Markdown file to write, or - for stdout")
|
|
1251
1355
|
ap.add_argument("--max-rows", type=int, default=0,
|
|
1252
1356
|
help="xlsx only: keep at most this many data rows per sheet (0 = all)")
|
|
1357
|
+
ap.add_argument("--ocr-lang", default="eng",
|
|
1358
|
+
help="pdf only: tesseract language for scanned pages, e.g. eng, deu, fra, eng+fra (default eng)")
|
|
1359
|
+
ap.add_argument("--no-ocr", action="store_true", help="pdf only: never run OCR on scanned pages")
|
|
1253
1360
|
a = ap.parse_args(argv)
|
|
1361
|
+
if not re.fullmatch(r"[A-Za-z_]{3,}(\+[A-Za-z_]{3,})*", a.ocr_lang):
|
|
1362
|
+
sys.exit("error: --ocr-lang takes tesseract language codes such as eng or eng+fra")
|
|
1254
1363
|
|
|
1255
1364
|
path = Path(a.input)
|
|
1256
1365
|
if not path.is_file():
|
|
@@ -1267,7 +1376,8 @@ def main(argv=None):
|
|
|
1267
1376
|
md += "\n"
|
|
1268
1377
|
elif ext in HANDLERS:
|
|
1269
1378
|
fn = HANDLERS[ext]
|
|
1270
|
-
md = fn(path, rep, a.max_rows) if fn is xlsx_to_md else
|
|
1379
|
+
md = (fn(path, rep, a.max_rows) if fn is xlsx_to_md else
|
|
1380
|
+
fn(path, rep, ocr=not a.no_ocr, ocr_lang=a.ocr_lang) if fn is pdf_to_md else fn(path, rep))
|
|
1271
1381
|
else:
|
|
1272
1382
|
sys.exit("error: unsupported file type %r. Supported: %s, .txt, .md"
|
|
1273
1383
|
% (ext, ", ".join(sorted(HANDLERS))))
|
|
@@ -66,6 +66,13 @@ has a shaded header row and right-aligned numbers, and the 480 × 240 px chart (
|
|
|
66
66
|
DPI) is centred at 3.3 × 1.7 in. The numbered proposals run 1 to 3, the "support dashboard" and
|
|
67
67
|
"the alias section" links are blue and underlined, and the quote is indented and italic.
|
|
68
68
|
|
|
69
|
+
**Opened in Microsoft Word** for Mac 16.114 (2026-10-20), through AppleScript: it opened with
|
|
70
|
+
no repair prompt, the first paragraph has the built-in Heading 1 style, and it holds one table
|
|
71
|
+
and one inline picture. Word's own PDF export of it shows the nested bullets, both task boxes,
|
|
72
|
+
the table with its numbers right-aligned, the code block and the quote, embeds the chart at
|
|
73
|
+
480 × 240, and carries both links as live link annotations: the dashboard URL and the jump to
|
|
74
|
+
"Retiring the alias".
|
|
75
|
+
|
|
69
76
|
---
|
|
70
77
|
|
|
71
78
|
## Reply to the user
|
|
@@ -76,7 +76,12 @@ all well-formed. Row 2 of the first sheet reads back as:
|
|
|
76
76
|
|
|
77
77
|
The frozen pane is `ySplit="1"` and the autofilter is `A1:G5`. A Quick Look preview shows
|
|
78
78
|
`$1,240,500.00`, `12.5%` and `2027-03-31` as formatted values. Quick Look leaves boolean cells
|
|
79
|
-
blank, which is a limitation of the previewer.
|
|
79
|
+
blank, which is a limitation of the previewer.
|
|
80
|
+
|
|
81
|
+
**Opened in Microsoft Excel** for Mac 16.114 (2026-10-20), read through AppleScript without
|
|
82
|
+
saving: three sheets; D2 is the number 1240500 shown as `$1,240,500.00`, E2 the number 0.125
|
|
83
|
+
shown as `12.5%`, F2 a date shown as `2027-03-31`, G2 the boolean `TRUE`, and A2 the text
|
|
84
|
+
`00412` with its zeros. The first sheet has its filter on and its header row frozen.
|
|
80
85
|
|
|
81
86
|
---
|
|
82
87
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brand-kit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
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,7 +42,7 @@ 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())
|
|
45
|
+
brand = json.loads(path.read_text(encoding="utf-8"))
|
|
46
46
|
except (OSError, json.JSONDecodeError) as e:
|
|
47
47
|
print(f"FAIL cannot read {path}: {e}")
|
|
48
48
|
return 1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "deck-builder",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
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": {
|
|
@@ -161,7 +161,7 @@ def resolve_palette(p, script_dir: Path) -> dict:
|
|
|
161
161
|
if not palettes_path.exists():
|
|
162
162
|
warn(f"palettes file missing at {palettes_path}; using defaults")
|
|
163
163
|
return DEFAULT_PALETTE
|
|
164
|
-
all_palettes = json.loads(palettes_path.read_text())
|
|
164
|
+
all_palettes = json.loads(palettes_path.read_text(encoding="utf-8"))
|
|
165
165
|
if p not in all_palettes:
|
|
166
166
|
warn(f"palette {p!r} not found; using defaults")
|
|
167
167
|
return DEFAULT_PALETTE
|
|
@@ -965,7 +965,7 @@ def load_brand(ref, base: Path):
|
|
|
965
965
|
warn(f"brand file not found: {path}; building without it")
|
|
966
966
|
return {}, base
|
|
967
967
|
try:
|
|
968
|
-
return json.loads(path.read_text()), path.parent
|
|
968
|
+
return json.loads(path.read_text(encoding="utf-8")), path.parent
|
|
969
969
|
except json.JSONDecodeError as e:
|
|
970
970
|
warn(f"brand file {path} is not valid JSON ({e}); building without it")
|
|
971
971
|
return {}, base
|
|
@@ -1024,7 +1024,7 @@ def place_logo(slide, c: "Canvas", palette: dict, logical: str) -> None:
|
|
|
1024
1024
|
|
|
1025
1025
|
|
|
1026
1026
|
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())
|
|
1027
|
+
spec = json.loads(spec_path.read_text(encoding="utf-8"))
|
|
1028
1028
|
spec_dir = spec_path.parent
|
|
1029
1029
|
brand, brand_dir = load_brand(brand_ref or spec.get("brand"), spec_dir)
|
|
1030
1030
|
if brand:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "marketing-flyer",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
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": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pre-merge-review",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
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": [] },
|
|
@@ -43,7 +43,7 @@ def detect(root):
|
|
|
43
43
|
|
|
44
44
|
if has("package.json"):
|
|
45
45
|
try:
|
|
46
|
-
scripts = json.load(open(os.path.join(root, "package.json"))).get("scripts", {})
|
|
46
|
+
scripts = json.load(open(os.path.join(root, "package.json"), encoding="utf-8")).get("scripts", {})
|
|
47
47
|
except (OSError, json.JSONDecodeError):
|
|
48
48
|
scripts = {}
|
|
49
49
|
if "lint" in scripts:
|