proskills-cli 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,83 @@
1
- # Temporary Holding Version
1
+ # proskills-cli
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Install [ProSkills](https://proskills.dev) agent skills in one command, and scan any `SKILL.md` folder in CI.
4
+
5
+ Node 18 or newer. One dependency (`jszip`).
6
+
7
+ ## Use it
8
+
9
+ No install needed:
10
+
11
+ ```sh
12
+ npx proskills-cli list # every skill, price and scan score
13
+ npx proskills-cli add humanizer # install a free skill into this project
14
+ npx proskills-cli add grill-me hook-lab # several at once
15
+ npx proskills-cli scan # score the skills already in this repo
16
+ ```
17
+
18
+ Or install it: `npm i -g proskills`.
19
+
20
+ ### `proskills list [--collection <slug>]`
21
+
22
+ Prints a table of skills with price (or `Free`) and scan score.
23
+
24
+ ### `proskills add <skill-id...>`
25
+
26
+ | Flag | Meaning |
27
+ |---|---|
28
+ | `--agent claude\|cursor\|codex\|gemini\|all` | Which agent to install for. Default: `.claude/` found, then Claude Code; else `.agents/` found, then `.agents/skills`; else Claude Code. |
29
+ | `--global` | Install for every project (your home folder) instead of this one. |
30
+ | `--order <id>` | Order id from your receipt (`cs_live_...`). Also read from env `PROSKILLS_ORDER`. Required for paid skills. |
31
+ | `--dir <path>` | Use this folder instead of the current one (instead of your home folder with `--global`). |
32
+ | `--force` | Replace a skill folder that already exists. Without it, nothing is overwritten. |
33
+ | `--answers file.json` | Send your saved answers so `profile.md` is filled in. Optional. |
34
+
35
+ Paid skill without an order id: the command stops and prints the link to buy it (`https://proskills.dev/library/<collection>`).
36
+
37
+ ### `proskills scan [paths...] [--min 80] [--json]`
38
+
39
+ Scores skill folders against the Agent Skills spec, token budget, clarity and security. A path is a skill folder (has `SKILL.md`) or a folder of skill folders. Default: `.claude/skills` and `.agents/skills`. Exits `1` when any skill is grade F (any critical finding) or scores under `--min`, `2` on usage errors. Scanning runs locally; nothing is uploaded.
40
+
41
+ ### Everywhere
42
+
43
+ `--base <url>` (or env `PROSKILLS_URL`) points at another host. Default `https://proskills.dev`. `--help`, `--version`.
44
+
45
+ ## CI
46
+
47
+ Block a pull request that adds a weak or unsafe skill:
48
+
49
+ ```yaml
50
+ name: skills
51
+ on: [pull_request]
52
+ jobs:
53
+ scan:
54
+ runs-on: ubuntu-latest
55
+ steps:
56
+ - uses: actions/checkout@v4
57
+ - uses: actions/setup-node@v4
58
+ with: { node-version: 20 }
59
+ - run: npx proskills-cli scan --min 80
60
+ ```
61
+
62
+ Add paths to scan something else: `npx proskills-cli scan ./skills --min 90 --json`.
63
+
64
+ ## What it writes, and where
65
+
66
+ Each skill becomes one folder, with `SKILL.md`, `profile.md` and any files the skill reads:
67
+
68
+ | Agent | This project | With `--global` |
69
+ |---|---|---|
70
+ | Claude Code | `.claude/skills/<id>/` | `~/.claude/skills/<id>/` |
71
+ | Cursor | `.agents/skills/<id>/` | `~/.cursor/skills/<id>/` |
72
+ | Codex | `.agents/skills/<id>/` | `~/.agents/skills/<id>/` |
73
+ | Gemini CLI | `.agents/skills/<id>/` | `~/.gemini/skills/<id>/` |
74
+
75
+ It writes only inside that skills folder. Archive entries with `..`, absolute paths or drive letters are rejected before anything is written. Restart your agent afterwards.
76
+
77
+ ## Privacy
78
+
79
+ `list` and `add` send only the skill ids you ask for, plus the order id if you give one. Your saved answers are not sent unless you pass `--answers file.json`. No telemetry, no accounts, no other network calls. `scan` makes none at all.
80
+
81
+ ## License
82
+
83
+ MIT
package/dist/api.js ADDED
@@ -0,0 +1,103 @@
1
+ import JSZip from "jszip";
2
+ import { safeRelPath } from "./paths.js";
3
+ export class CliError extends Error {
4
+ code;
5
+ constructor(message, code = 1) {
6
+ super(message);
7
+ this.code = code;
8
+ }
9
+ }
10
+ const MAX_ZIP_BYTES = 50 * 1024 * 1024;
11
+ const MAX_ENTRIES = 2000;
12
+ const MAX_UNPACKED_BYTES = 100 * 1024 * 1024;
13
+ function headers(version, json) {
14
+ return { "User-Agent": `proskills-cli/${version}`, ...(json ? { "Content-Type": "application/json" } : {}) };
15
+ }
16
+ async function request(f, url, init) {
17
+ try {
18
+ return await f(url, { ...init, signal: AbortSignal.timeout(30_000) });
19
+ }
20
+ catch (e) {
21
+ throw new CliError(`Could not reach ${new URL(url).origin}: ${e instanceof Error ? e.message : String(e)}`);
22
+ }
23
+ }
24
+ export async function fetchCatalog(f, base, version) {
25
+ const res = await request(f, `${base}/api/catalog`, { headers: headers(version, false) });
26
+ if (!res.ok)
27
+ throw new CliError(`Catalog request failed (${res.status}) at ${base}/api/catalog`);
28
+ let data;
29
+ try {
30
+ data = (await res.json());
31
+ }
32
+ catch {
33
+ throw new CliError(`${base}/api/catalog did not return JSON. Is --base correct?`);
34
+ }
35
+ if (!data || !Array.isArray(data.skills) || !Array.isArray(data.collections))
36
+ throw new CliError(`Unexpected catalog format from ${base}.`);
37
+ return data;
38
+ }
39
+ export async function downloadZip(f, base, version, body) {
40
+ const res = await request(f, `${base}/api/download`, { method: "POST", headers: headers(version, true), body: JSON.stringify(body) });
41
+ if (!res.ok) {
42
+ let msg = "";
43
+ try {
44
+ msg = (await res.json()).error ?? "";
45
+ }
46
+ catch {
47
+ /* not JSON */
48
+ }
49
+ if (res.status === 402)
50
+ throw new CliError(`${msg || "Payment required."} Buy at ${base}/library/${body.slug} and pass the order id with --order or PROSKILLS_ORDER.`);
51
+ throw new CliError(`Download failed (${res.status})${msg ? `: ${msg}` : ""}`);
52
+ }
53
+ const buf = new Uint8Array(await res.arrayBuffer());
54
+ if (buf.length > MAX_ZIP_BYTES)
55
+ throw new CliError("Download is unexpectedly large; refusing to unpack it.");
56
+ return buf;
57
+ }
58
+ /**
59
+ * Reads a /api/download zip into `skill id -> files`. Handles both layouts
60
+ * (single skill: files at the zip root; several: `proskills-<slug>/<id>/…`).
61
+ * Throws before returning anything when any entry name is unsafe.
62
+ */
63
+ export async function readSkills(zipData, slug, ids) {
64
+ const zip = await JSZip.loadAsync(zipData);
65
+ const entries = Object.values(zip.files);
66
+ if (entries.length > MAX_ENTRIES)
67
+ throw new CliError("Archive has too many entries.");
68
+ const names = entries.map((e) => ({ e, rel: safeRelPath(e.name) })).filter((x) => x.rel !== "");
69
+ const flat = names.some((x) => x.rel === "SKILL.md") && ids.length === 1;
70
+ const prefix = `proskills-${slug}`;
71
+ const out = new Map(ids.map((id) => [id, []]));
72
+ let total = 0;
73
+ for (const { e, rel } of names) {
74
+ if (e.dir)
75
+ continue;
76
+ let parts = rel.split("/");
77
+ let id;
78
+ if (flat) {
79
+ if (rel === "INSTALL.md")
80
+ continue;
81
+ id = ids[0];
82
+ }
83
+ else {
84
+ if (parts[0] === prefix)
85
+ parts = parts.slice(1);
86
+ if (parts.length < 2)
87
+ continue; // bundle-level README.md
88
+ id = parts[0];
89
+ parts = parts.slice(1);
90
+ if (!out.has(id))
91
+ continue;
92
+ }
93
+ const data = await e.async("uint8array");
94
+ total += data.length;
95
+ if (total > MAX_UNPACKED_BYTES)
96
+ throw new CliError("Archive unpacks to an unexpected size; refusing.");
97
+ out.get(id).push({ path: parts.join("/"), data });
98
+ }
99
+ for (const [id, files] of out)
100
+ if (!files.some((f) => f.path === "SKILL.md"))
101
+ throw new CliError(`The download for "${id}" did not contain a SKILL.md.`);
102
+ return out;
103
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,163 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { isAbsolute, relative, resolve } from "node:path";
4
+ import { parseArgs } from "node:util";
5
+ import { CliError, fetchCatalog } from "./api.js";
6
+ import { install } from "./install.js";
7
+ import { pickAgents, resolveTargets } from "./paths.js";
8
+ import { DEFAULT_SCAN_DIRS, isFailing, scanDirs } from "./scancmd.js";
9
+ export const DEFAULT_BASE = "https://proskills.dev";
10
+ export const HELP = `proskills - install ProSkills agent skills and scan any skill folder
11
+
12
+ Usage
13
+ proskills list [--collection <slug>]
14
+ proskills add <skill-id...> [--agent claude|cursor|codex|gemini|all] [--global]
15
+ [--order <id>] [--dir <path>] [--force] [--answers file.json]
16
+ proskills scan [paths...] [--min 80] [--json]
17
+
18
+ Options
19
+ --base <url> Another host (default ${DEFAULT_BASE}, or env PROSKILLS_URL)
20
+ --order <id> Order id from your receipt (or env PROSKILLS_ORDER)
21
+ --dir <path> Base folder instead of the current one (home folder with --global)
22
+ --help Show this help
23
+ --version Show the version
24
+
25
+ Examples
26
+ npx proskills-cli add humanizer
27
+ npx proskills-cli add grill-me --agent all --global
28
+ npx proskills-cli scan --min 80
29
+ `;
30
+ const OPTIONS = {
31
+ help: { type: "boolean", short: "h" },
32
+ version: { type: "boolean", short: "v" },
33
+ base: { type: "string" },
34
+ agent: { type: "string" },
35
+ global: { type: "boolean" },
36
+ order: { type: "string" },
37
+ dir: { type: "string" },
38
+ force: { type: "boolean" },
39
+ answers: { type: "string" },
40
+ collection: { type: "string" },
41
+ min: { type: "string" },
42
+ json: { type: "boolean" },
43
+ };
44
+ export function parseCli(argv) {
45
+ const { values, positionals } = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true, strict: true });
46
+ return { command: positionals[0], args: positionals.slice(1), values };
47
+ }
48
+ export function defaultEnv(version) {
49
+ return {
50
+ fetch: globalThis.fetch,
51
+ cwd: process.cwd(),
52
+ home: homedir(),
53
+ env: process.env,
54
+ out: (s) => process.stdout.write(s + "\n"),
55
+ err: (s) => process.stderr.write(s + "\n"),
56
+ version,
57
+ };
58
+ }
59
+ const money = (n) => (n === 0 ? "Free" : `$${n}`);
60
+ function table(rows) {
61
+ const widths = rows[0].map((_, i) => Math.max(...rows.map((r) => r[i].length)));
62
+ return rows.map((r) => r.map((c, i) => (i === r.length - 1 ? c : c.padEnd(widths[i]))).join(" ")).join("\n");
63
+ }
64
+ function readAnswers(file, cwd) {
65
+ try {
66
+ const data = JSON.parse(readFileSync(resolve(cwd, file), "utf8"));
67
+ if (!data || typeof data !== "object" || Array.isArray(data))
68
+ throw new Error("expected a JSON object");
69
+ return data;
70
+ }
71
+ catch (e) {
72
+ throw new CliError(`Could not read --answers ${file}: ${e instanceof Error ? e.message : String(e)}`, 2);
73
+ }
74
+ }
75
+ /** Runs the CLI and returns the exit code. Never calls process.exit, so tests can drive it. */
76
+ export async function run(argv, io) {
77
+ try {
78
+ let parsed;
79
+ try {
80
+ parsed = parseCli(argv);
81
+ }
82
+ catch (e) {
83
+ throw new CliError(`${e instanceof Error ? e.message : String(e)}\nRun \`proskills --help\` for usage.`, 2);
84
+ }
85
+ const { command, args, values } = parsed;
86
+ if (values.version) {
87
+ io.out(io.version);
88
+ return 0;
89
+ }
90
+ if (values.help || !command || command === "help") {
91
+ io.out(HELP);
92
+ return command || values.help ? 0 : 2;
93
+ }
94
+ const base = (values.base ?? io.env.PROSKILLS_URL ?? DEFAULT_BASE).replace(/\/+$/, "");
95
+ if (command === "list") {
96
+ const cat = await fetchCatalog(io.fetch, base, io.version);
97
+ if (values.collection && !cat.collections.some((c) => c.slug === values.collection)) {
98
+ throw new CliError(`Unknown collection "${values.collection}". Collections: ${cat.collections.map((c) => c.slug).join(", ")}`);
99
+ }
100
+ const skills = cat.skills.filter((s) => !values.collection || s.slug === values.collection);
101
+ const rows = [["ID", "COLLECTION", "PRICE", "SCAN", "NAME"], ...skills.map((s) => [s.id, s.slug, money(s.price), s.quality ? `${s.quality.score} ${s.quality.grade}` : "-", s.name])];
102
+ io.out(table(rows));
103
+ io.out(`\n${skills.length} skills. Install with: npx proskills-cli add <id>`);
104
+ return 0;
105
+ }
106
+ if (command === "add") {
107
+ if (!args.length)
108
+ throw new CliError("Name at least one skill, e.g. `proskills add humanizer`. See `proskills list`.", 2);
109
+ const global = !!values.global;
110
+ const baseDir = resolve(io.cwd, values.dir ?? (global ? io.home : "."));
111
+ let agents;
112
+ try {
113
+ agents = pickAgents(values.agent, baseDir, global);
114
+ }
115
+ catch (e) {
116
+ throw new CliError(e.message, 2);
117
+ }
118
+ const targets = resolveTargets(agents, baseDir, global);
119
+ const order = values.order ?? io.env.PROSKILLS_ORDER ?? undefined;
120
+ const answers = values.answers ? readAnswers(values.answers, io.cwd) : {};
121
+ const catalog = await fetchCatalog(io.fetch, base, io.version);
122
+ const written = await install({ ids: args, catalog, base, version: io.version, fetch: io.fetch, targets, order: order || undefined, answers, force: !!values.force });
123
+ for (const w of written) {
124
+ const rel = relative(io.cwd, w.dir);
125
+ const shown = rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : w.dir;
126
+ io.out(`wrote ${shown.replace(/\\/g, "/")}/ (${w.files} files, ${w.target.label})`);
127
+ }
128
+ io.out("\nRestart your agent (or start a new session) to load the skill.");
129
+ return 0;
130
+ }
131
+ if (command === "scan") {
132
+ const min = values.min === undefined ? 80 : Number(values.min);
133
+ if (!Number.isFinite(min) || min < 0 || min > 100)
134
+ throw new CliError("--min must be a number from 0 to 100.", 2);
135
+ const explicit = args.length > 0;
136
+ const results = scanDirs(explicit ? args : DEFAULT_SCAN_DIRS, io.cwd);
137
+ if (!results.length)
138
+ throw new CliError(explicit ? "No skill folders (containing SKILL.md) found in the given paths." : `No skills found in ${DEFAULT_SCAN_DIRS.join(" or ")}. Pass a path: proskills scan ./my-skill`, 2);
139
+ const failed = results.filter((r) => isFailing(r, min));
140
+ if (values.json) {
141
+ io.out(JSON.stringify({ min, ok: failed.length === 0, skills: results.map((r) => ({ dir: r.dir.replace(/\\/g, "/"), failed: isFailing(r, min), ...r.report })) }, null, 2));
142
+ }
143
+ else {
144
+ for (const { dir, report: r } of results) {
145
+ io.out(`\n${r.grade} ${String(r.score).padStart(3)} ${dir.replace(/\\/g, "/")} spec ${r.pillars.spec} · eff ${r.pillars.efficiency} · clarity ${r.pillars.clarity} · sec ${r.pillars.security}`);
146
+ for (const f of r.findings.filter((f) => f.severity !== "info"))
147
+ io.out(` ${f.severity.padEnd(8)} ${f.rule.padEnd(24)} ${f.file ?? ""}${f.line ? ":" + f.line : ""} ${f.message}`);
148
+ }
149
+ io.out(`\n${results.length} skills scanned, ${failed.length} below the gate (min ${min}, grade F always fails).`);
150
+ }
151
+ return failed.length ? 1 : 0;
152
+ }
153
+ throw new CliError(`Unknown command "${command}". Run \`proskills --help\`.`, 2);
154
+ }
155
+ catch (e) {
156
+ if (e instanceof CliError) {
157
+ io.err(`proskills: ${e.message}`);
158
+ return e.code;
159
+ }
160
+ io.err(`proskills: ${e instanceof Error ? e.message : String(e)}`);
161
+ return 1;
162
+ }
163
+ }
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import { defaultEnv, run } from "./cli.js";
4
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
5
+ process.exitCode = await run(process.argv.slice(2), defaultEnv(pkg.version));
@@ -0,0 +1,61 @@
1
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { CliError, downloadZip, readSkills } from "./api.js";
4
+ import { insideRoot, safeRelPath } from "./paths.js";
5
+ export const ID_RE = /^[a-z0-9][a-z0-9-]{0,60}$/;
6
+ export const ORDER_RE = /^cs_(test|live)_[A-Za-z0-9]{10,200}$/;
7
+ /** Resolves ids against the catalog and checks purchase state. Throws CliError with the next step. */
8
+ export function plan(ids, catalog, base, order) {
9
+ const unique = [...new Set(ids)];
10
+ for (const id of unique)
11
+ if (!ID_RE.test(id))
12
+ throw new CliError(`"${id}" is not a valid skill id.`, 2);
13
+ const unknown = unique.filter((id) => !catalog.skills.some((s) => s.id === id));
14
+ if (unknown.length)
15
+ throw new CliError(`Unknown skill${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")}. Run \`proskills list\` to see every id.`);
16
+ if (order && !ORDER_RE.test(order))
17
+ throw new CliError("That order id doesn't look right. It starts with cs_live_ or cs_test_ and is on your receipt.", 2);
18
+ const groups = new Map();
19
+ const locked = [];
20
+ for (const id of unique) {
21
+ const s = catalog.skills.find((k) => k.id === id);
22
+ if (!s.free && !order)
23
+ locked.push(` ${id} ($${s.price}) ${base}/library/${s.slug}`);
24
+ groups.set(s.slug, [...(groups.get(s.slug) ?? []), id]);
25
+ }
26
+ if (locked.length) {
27
+ throw new CliError(`These skills are paid and need an order id:\n${locked.join("\n")}\nBuy at the link, then rerun with --order <id> (or set PROSKILLS_ORDER).`);
28
+ }
29
+ return groups;
30
+ }
31
+ export async function install(o) {
32
+ const groups = plan(o.ids, o.catalog, o.base, o.order);
33
+ const ids = [...groups.values()].flat();
34
+ if (!o.force) {
35
+ const clashes = o.targets.flatMap((t) => ids.map((id) => insideRoot(t.dir, id)).filter((p) => existsSync(p)));
36
+ if (clashes.length)
37
+ throw new CliError(`Already installed, not overwriting:\n${clashes.map((c) => " " + c).join("\n")}\nUse --force to replace.`);
38
+ }
39
+ // Download and validate everything before touching the disk.
40
+ const staged = new Map();
41
+ for (const [slug, groupIds] of groups) {
42
+ const zip = await downloadZip(o.fetch, o.base, o.version, { slug, skills: groupIds, answers: o.answers, ...(o.order ? { order: o.order } : {}) });
43
+ for (const [id, files] of await readSkills(zip, slug, groupIds))
44
+ staged.set(id, files);
45
+ }
46
+ const written = [];
47
+ for (const t of o.targets) {
48
+ for (const [id, files] of staged) {
49
+ const dir = insideRoot(t.dir, id);
50
+ if (o.force && existsSync(dir))
51
+ rmSync(dir, { recursive: true, force: true });
52
+ for (const f of files) {
53
+ const dest = insideRoot(dir, safeRelPath(f.path));
54
+ mkdirSync(dirname(dest), { recursive: true });
55
+ writeFileSync(dest, f.data);
56
+ }
57
+ written.push({ id, target: t, dir, files: files.length });
58
+ }
59
+ }
60
+ return written;
61
+ }
package/dist/paths.js ADDED
@@ -0,0 +1,79 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join, resolve, sep } from "node:path";
3
+ export const AGENTS = ["claude", "cursor", "codex", "gemini"];
4
+ /** Project-level skills directory per agent (relative to the project root). */
5
+ const PROJECT_DIR = {
6
+ claude: ".claude/skills",
7
+ cursor: ".agents/skills",
8
+ codex: ".agents/skills",
9
+ gemini: ".agents/skills",
10
+ };
11
+ /** User-level skills directory per agent (relative to the home directory). */
12
+ const USER_DIR = {
13
+ claude: ".claude/skills",
14
+ cursor: ".cursor/skills",
15
+ codex: ".agents/skills",
16
+ gemini: ".gemini/skills",
17
+ };
18
+ export function isAgent(v) {
19
+ return AGENTS.includes(v);
20
+ }
21
+ /**
22
+ * Which agents to install for. With no --agent: `.claude/` present → claude,
23
+ * else `.agents/` present → the shared .agents folder (Cursor, Codex, Gemini),
24
+ * else claude. `all` installs for every agent (deduplicated by folder).
25
+ */
26
+ export function pickAgents(agent, base, global) {
27
+ if (agent === "all")
28
+ return AGENTS;
29
+ if (agent) {
30
+ if (!isAgent(agent))
31
+ throw new Error(`Unknown agent "${agent}". Use claude, cursor, codex, gemini or all.`);
32
+ return [agent];
33
+ }
34
+ if (global)
35
+ return ["claude"];
36
+ if (existsSync(join(base, ".claude")))
37
+ return ["claude"];
38
+ if (existsSync(join(base, ".agents")))
39
+ return ["codex"];
40
+ return ["claude"];
41
+ }
42
+ /** Skills directories for the chosen agents. `base` is the project root, or the home directory with --global. */
43
+ export function resolveTargets(agents, base, global) {
44
+ const seen = new Map();
45
+ for (const a of agents) {
46
+ const dir = resolve(base, (global ? USER_DIR : PROJECT_DIR)[a]);
47
+ const prev = seen.get(dir);
48
+ if (prev) {
49
+ prev.label += `, ${a}`;
50
+ }
51
+ else {
52
+ seen.set(dir, { label: a, dir });
53
+ }
54
+ }
55
+ return [...seen.values()];
56
+ }
57
+ /**
58
+ * Normalises a zip entry name to a relative POSIX path, or throws when it
59
+ * could escape the destination (absolute path, drive letter, `..`, NUL).
60
+ */
61
+ export function safeRelPath(name) {
62
+ if (name.includes("\0"))
63
+ throw new Error(`Unsafe path in archive: ${JSON.stringify(name)}`);
64
+ const n = name.replace(/\\/g, "/");
65
+ if (n.startsWith("/") || /^[A-Za-z]:/.test(n))
66
+ throw new Error(`Unsafe path in archive: ${name}`);
67
+ const parts = n.split("/").filter((p) => p !== "" && p !== ".");
68
+ if (parts.some((p) => p === ".."))
69
+ throw new Error(`Unsafe path in archive: ${name}`);
70
+ return parts.join("/");
71
+ }
72
+ /** Joins and verifies the result stays inside `root`. */
73
+ export function insideRoot(root, rel) {
74
+ const full = resolve(root, rel);
75
+ const r = resolve(root);
76
+ if (full !== r && !full.startsWith(r.endsWith(sep) ? r : r + sep))
77
+ throw new Error(`Refusing to write outside ${r}: ${rel}`);
78
+ return full;
79
+ }
package/dist/scan.js ADDED
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Skill Scanner: scores an Agent Skill (SKILL.md plus any files beside it)
3
+ * against the open Agent Skills spec, progressive-disclosure and token
4
+ * budgets, clarity, and security. Pure TypeScript with no Node or DOM APIs,
5
+ * so the same code gates our catalog at build time (scripts/scan.ts) and
6
+ * runs in the visitor's browser on /scan, where nothing is uploaded.
7
+ *
8
+ * Every finding names its rule, so a score is always explainable. Four
9
+ * pillars of 25 points each make the 0–100 score; any critical security
10
+ * finding caps the grade at F regardless of the rest.
11
+ */
12
+ export const PILLAR_MAX = 25;
13
+ /** Rough token count; within ~10% of real tokenizers for English and code. */
14
+ export function tokens(text) {
15
+ return Math.ceil(text.length / 4);
16
+ }
17
+ /** Minimal frontmatter reader: top-level `key: value` pairs, folded/quoted values. */
18
+ export function parseFrontmatter(src) {
19
+ const text = src.replace(/^/, "").replace(/\r\n/g, "\n");
20
+ if (!text.startsWith("---\n"))
21
+ return { data: {}, body: text, bodyStart: 1, error: "missing", raw: "" };
22
+ const end = text.indexOf("\n---", 4);
23
+ if (end < 0)
24
+ return { data: {}, body: text, bodyStart: 1, error: "unterminated", raw: "" };
25
+ const raw = text.slice(4, end);
26
+ const after = text.indexOf("\n", end + 4);
27
+ const body = after < 0 ? "" : text.slice(after + 1);
28
+ const data = {};
29
+ let error;
30
+ let key = null;
31
+ let block = false;
32
+ for (const line of raw.split("\n")) {
33
+ const m = /^([A-Za-z][\w-]*):\s?(.*)$/.exec(line);
34
+ if (m && !line.startsWith(" ")) {
35
+ key = m[1];
36
+ let v = m[2].trim();
37
+ block = v === "|" || v === ">" || v === "|-" || v === ">-";
38
+ if (block)
39
+ v = "";
40
+ else if (/^(['"]).*\1$/.test(v))
41
+ v = v.slice(1, -1);
42
+ else if (/:\s/.test(v) && !/^[[{]/.test(v))
43
+ error = `"${key}" has an unquoted ": " — YAML parsers will reject or mangle it`;
44
+ data[key] = v;
45
+ }
46
+ else if (key && (line.startsWith(" ") || line.startsWith("\t"))) {
47
+ data[key] = (data[key] ? data[key] + (block ? "\n" : " ") : "") + line.trim();
48
+ }
49
+ }
50
+ return { data, body, bodyStart: raw.split("\n").length + 3, error, raw };
51
+ }
52
+ const NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
53
+ const RESERVED = ["anthropic", "claude"];
54
+ /** Security patterns. Matched per line across every text file in the skill. */
55
+ const SECURITY = [
56
+ { rule: "sec/prompt-injection", severity: "critical", cost: 25, re: /\b(ignore|disregard|forget)\s+(all\s+)?(the\s+)?(previous|prior|above|earlier|system)\s+(instructions|prompts?|rules|messages)/i, message: "Tries to override the agent's instructions." },
57
+ { rule: "sec/prompt-injection", severity: "critical", cost: 25, re: /\b(you are now|new system prompt|developer mode|jailbreak|DAN mode)\b/i, message: "Role-hijack phrasing." },
58
+ { rule: "sec/conceal", severity: "critical", cost: 25, re: /\b(do not|don't|never)\s+(tell|inform|mention|reveal|show)\s+(this\s+to\s+)?(the\s+)?user\b/i, message: "Asks the agent to hide actions from the user." },
59
+ { rule: "sec/remote-exec", severity: "critical", cost: 25, re: /\b(curl|wget|iwr|Invoke-WebRequest)\b[^\n|]*\|\s*(ba|z)?sh\b|\b(curl|wget)\b[^\n|]*\|\s*(python|node|iex)\b|\biex\s*\(\s*(New-Object|iwr|irm)/i, message: "Downloads and executes remote code." },
60
+ { rule: "sec/exfiltration", severity: "critical", cost: 25, re: /\b(curl|wget|fetch|requests\.(post|get)|axios|http\.request)\b[^\n]*(\$\{?[A-Z_]*(KEY|TOKEN|SECRET|PASSWORD)|\.env\b|id_rsa|\.ssh|\.aws\/credentials|process\.env|os\.environ)/i, message: "Sends secrets or credentials over the network." },
61
+ { rule: "sec/secret-read", severity: "warn", cost: 10, re: /(~|\$HOME|%USERPROFILE%)?[\\/]\.(ssh|aws|gnupg|kube|docker)[\\/]|\bid_(rsa|ed25519)\b|\.npmrc|\.netrc|\bkeychain\b/i, message: "Touches credential files." },
62
+ { rule: "sec/destructive", severity: "critical", cost: 20, re: /\brm\s+-[a-z]*r[a-z]*f?[a-z]*\s+(\/|~|\$HOME|\*|\.\s*$)|\bmkfs\b|\bdd\s+if=.*of=\/dev\/|:\(\)\s*\{\s*:\|:&\s*\};:|\bformat\s+[a-z]:/i, message: "Destructive filesystem command." },
63
+ { rule: "sec/destructive", severity: "warn", cost: 8, re: /\bgit\s+push\s+(-f|--force)\b|\bgit\s+reset\s+--hard\b|\bDROP\s+(TABLE|DATABASE)\b|--no-verify\b|--dangerously/i, message: "Risky command; make sure the skill asks before running it." },
64
+ { rule: "sec/obfuscation", severity: "critical", cost: 20, re: /\b(base64\s+(-d|--decode)|atob\(|FromBase64String|eval\s*\(|exec\s*\(\s*(compile|base64|bytes))/i, message: "Decodes or evaluates hidden code." },
65
+ { rule: "sec/obfuscation", severity: "warn", cost: 6, re: /[A-Za-z0-9+/]{120,}={0,2}/, message: "Long encoded blob; reviewers can't read what it does." },
66
+ { rule: "sec/fake-prerequisite", severity: "critical", cost: 25, re: /\b(download|install|run)\b[^\n]{0,60}\bhttps?:\/\/[^\s)]+\.(dmg|pkg|exe|msi|zip|tar\.gz|sh|ps1|bin|AppImage)\b/i, message: "Tells the user or agent to fetch and run a binary, the pattern behind the ClawHavoc malware campaign." },
67
+ { rule: "sec/remote-instructions", severity: "warn", cost: 10, re: /\b(fetch|download|curl|read|load|get)\b[^\n]{0,40}\bhttps?:\/\/[^\s)]+[^\n]{0,60}\b(follow|apply|execute|obey)\b[^\n]{0,20}\b(instructions|rules|steps|prompt)/i, message: "Pulls instructions from a URL at runtime, so the skill can change after it was reviewed." },
68
+ { rule: "sec/persistence", severity: "warn", cost: 10, re: /\b(crontab|launchctl|systemctl\s+enable|schtasks|reg\s+add|\.bashrc|\.zshrc|\.profile|settings\.json.*hooks|Startup folder)\b/i, message: "Installs persistence or edits shell/agent config." },
69
+ { rule: "sec/permissions", severity: "warn", cost: 8, re: /--dangerously-skip-permissions|bypassPermissions|allowed-tools:\s*["']?\*|chmod\s+(-R\s+)?777|\bsudo\b/i, message: "Escalates permissions." },
70
+ ];
71
+ const HIDDEN = /[​-‏‪-‮⁠-⁤⁦-⁩\u{E0000}-\u{E007F}]/u;
72
+ const URL_RE = /\bhttps?:\/\/[^\s)<>"'`\]]+/g;
73
+ const VAGUE = /\b(as needed|if appropriate|when relevant|try to|you might|you may want to|consider (?:possibly)?|etc\.?|and so on|best practices|high[- ]quality|make sure it'?s good)\b/gi;
74
+ /** A referenced file is read by the skill (so must ship with it), not written by it. */
75
+ const READS = /\b(read|open|see|consult|load|follow|template|in this folder|bundled)\b/i;
76
+ const WRITES = /\b(write|writes|written|writing|read by|create|save|keep|update|generate|produce|look for|if it exists)\b/i;
77
+ /** Unfinished markers, skipping lines that ban them ("No lorem ipsum"). */
78
+ const TODO_RE = /^(?!.*\b(no|never|ban|avoid|don't|not)\b).*\b(TODO|FIXME|TBD|lorem ipsum)\b/im;
79
+ function lineAt(text, index) {
80
+ const start = text.lastIndexOf("\n", index) + 1;
81
+ const stop = text.indexOf("\n", index);
82
+ return text.slice(start, stop < 0 ? undefined : stop);
83
+ }
84
+ function lineOf(text, index) {
85
+ let n = 1;
86
+ for (let i = 0; i < index && i < text.length; i++)
87
+ if (text.charCodeAt(i) === 10)
88
+ n++;
89
+ return n;
90
+ }
91
+ function isText(path) {
92
+ return !/\.(png|jpe?g|gif|webp|mp4|mov|webm|pdf|zip|woff2?|ttf|ico|mp3|wav)$/i.test(path);
93
+ }
94
+ /** Folder the SKILL.md sits in, so "x/SKILL.md" and "SKILL.md" both work. */
95
+ function rootOf(files) {
96
+ const skill = [...files].sort((a, b) => a.path.length - b.path.length).find((f) => /(^|\/)SKILL\.md$/i.test(f.path));
97
+ const dir = skill ? skill.path.slice(0, skill.path.length - "SKILL.md".length) : "";
98
+ return { skill, dir };
99
+ }
100
+ export function scanSkill(input) {
101
+ const files = (typeof input === "string" ? [{ path: "SKILL.md", content: input }] : input).map((f) => ({ path: f.path.replace(/\\/g, "/"), content: f.content }));
102
+ const findings = [];
103
+ const add = (f) => findings.push(f);
104
+ const { skill, dir } = rootOf(files);
105
+ const rel = (p) => (p.startsWith(dir) ? p.slice(dir.length) : p);
106
+ if (!skill) {
107
+ add({ rule: "spec/no-skill-md", pillar: "spec", severity: "critical", cost: 25, message: "No SKILL.md found. Every skill needs one at its root." });
108
+ return finish(null, null, findings, { files: files.length, bodyLines: 0, bodyTokens: 0, metaTokens: 0, referenceTokens: 0, scripts: 0, links: [] });
109
+ }
110
+ const fm = parseFrontmatter(skill.content);
111
+ const name = fm.data.name ?? null;
112
+ const description = fm.data.description ?? null;
113
+ const body = fm.body;
114
+ const bodyLines = body.split("\n").length;
115
+ const others = files.filter((f) => f !== skill);
116
+ // ---- spec -------------------------------------------------------------
117
+ if (fm.error === "missing")
118
+ add({ rule: "spec/frontmatter", pillar: "spec", severity: "critical", cost: 25, message: "SKILL.md must start with YAML frontmatter (--- name, description ---).", file: "SKILL.md", line: 1 });
119
+ else if (fm.error === "unterminated")
120
+ add({ rule: "spec/frontmatter", pillar: "spec", severity: "critical", cost: 25, message: "Frontmatter is never closed with ---.", file: "SKILL.md", line: 1 });
121
+ else if (fm.error)
122
+ add({ rule: "spec/yaml", pillar: "spec", severity: "warn", cost: 8, message: `Frontmatter: ${fm.error}. Wrap the value in quotes.`, file: "SKILL.md", line: 2 });
123
+ if (!fm.error || fm.error.startsWith('"')) {
124
+ if (!name)
125
+ add({ rule: "spec/name", pillar: "spec", severity: "critical", cost: 12, message: "Missing required `name`.", file: "SKILL.md" });
126
+ else {
127
+ if (!NAME_RE.test(name))
128
+ add({ rule: "spec/name-format", pillar: "spec", severity: "warn", cost: 6, message: `name "${name}" must be lowercase letters, numbers and single hyphens.`, file: "SKILL.md" });
129
+ if (name.length > 64)
130
+ add({ rule: "spec/name-length", pillar: "spec", severity: "warn", cost: 4, message: `name is ${name.length} characters; the limit is 64.`, file: "SKILL.md" });
131
+ if (RESERVED.some((r) => name.includes(r)))
132
+ add({ rule: "spec/name-reserved", pillar: "spec", severity: "warn", cost: 4, message: `name contains a reserved word (${RESERVED.join(", ")}).`, file: "SKILL.md" });
133
+ const folder = dir.replace(/\/$/, "").split("/").pop();
134
+ if (folder && folder !== name)
135
+ add({ rule: "spec/name-folder", pillar: "spec", severity: "info", cost: 2, message: `name "${name}" doesn't match its folder "${folder}"; some agents key on the folder.`, file: "SKILL.md" });
136
+ }
137
+ if (!description)
138
+ add({ rule: "spec/description", pillar: "spec", severity: "critical", cost: 12, message: "Missing required `description`; agents can't know when to load the skill.", file: "SKILL.md" });
139
+ else {
140
+ if (description.length > 1024)
141
+ add({ rule: "spec/description-length", pillar: "spec", severity: "warn", cost: 6, message: `description is ${description.length} characters; the limit is 1024 and most agents truncate it.`, file: "SKILL.md" });
142
+ if (/<[a-z][^>]*>/i.test(description))
143
+ add({ rule: "spec/description-xml", pillar: "spec", severity: "warn", cost: 4, message: "description contains XML/HTML tags, which the spec forbids.", file: "SKILL.md" });
144
+ }
145
+ // The open spec's six fields. Claude Code ignores others, but claude.ai and the Skills API reject them.
146
+ const known = new Set(["name", "description", "license", "allowed-tools", "metadata", "compatibility"]);
147
+ const extra = Object.keys(fm.data).filter((k) => !known.has(k));
148
+ if (extra.length)
149
+ add({ rule: "spec/non-portable-keys", pillar: "spec", severity: "warn", cost: 4, message: `Non-spec frontmatter (${extra.join(", ")}). claude.ai and the Skills API reject the upload; move them under \`metadata\` or drop them.`, file: "SKILL.md" });
150
+ }
151
+ // Links inside SKILL.md must resolve to bundled files.
152
+ const present = new Set(others.map((f) => rel(f.path)));
153
+ const refRe = /(?:\]\(|`)((?:\.\/)?(?:[\w.-]+\/)*[\w.-]+\.(?:md|py|sh|js|mjs|ts|json|ya?ml|txt|csv|html))(?:\)|`)/g;
154
+ const referenced = new Set();
155
+ for (const m of body.matchAll(refRe)) {
156
+ const p = m[1].replace(/^\.\//, "");
157
+ if (p === "SKILL.md" || p === "profile.md" || /^(package|tsconfig)\.json$/.test(p))
158
+ continue;
159
+ if (/^[\w-]+\.(json|ya?ml|html|ts|js)$/.test(p) && !present.has(p))
160
+ continue; // likely a user-project file, not a bundled one
161
+ referenced.add(p);
162
+ // Only files the skill reads must ship with it; files it writes (decisions.md, storyboard.md) won't exist yet.
163
+ const ln = lineAt(body, m.index ?? 0);
164
+ const reads = READS.test(ln) && !WRITES.test(ln);
165
+ if (!present.has(p) && reads)
166
+ add({ rule: "spec/broken-ref", pillar: "spec", severity: "warn", cost: 4, message: `References ${p}, which isn't in the skill folder.`, file: "SKILL.md", line: fm.bodyStart + lineOf(body, m.index ?? 0) - 1 });
167
+ }
168
+ // ---- efficiency / progressive disclosure -------------------------------
169
+ const metaTokens = tokens(`${name ?? ""} ${description ?? ""}`);
170
+ const bodyTokens = tokens(body);
171
+ const referenceTokens = others.filter((f) => isText(f.path)).reduce((a, f) => a + tokens(f.content), 0);
172
+ const scripts = others.filter((f) => /\.(py|sh|js|mjs|ts|ps1|rb)$/i.test(f.path)).length;
173
+ if (description) {
174
+ if (description.length < 60)
175
+ add({ rule: "eff/description-thin", pillar: "efficiency", severity: "warn", cost: 6, message: "description is too short to trigger reliably. Say what it does and when to use it.", file: "SKILL.md" });
176
+ if (!/\b(use (it )?when|use for|use this|when the user|whenever|trigger|asks? (for|to))\b/i.test(description))
177
+ add({ rule: "eff/description-trigger", pillar: "efficiency", severity: "warn", cost: 6, message: "description doesn't say *when* to use the skill (\"Use when …\"). Agents decide from this line alone.", file: "SKILL.md" });
178
+ if (/^(this skill|a skill|i |i'm|you )/i.test(description))
179
+ add({ rule: "eff/description-voice", pillar: "efficiency", severity: "info", cost: 2, message: "Write the description in third person (\"Reviews…\", \"Use when…\"), not \"This skill…\" or \"I…\".", file: "SKILL.md" });
180
+ }
181
+ if (bodyLines > 500)
182
+ add({ rule: "eff/body-lines", pillar: "efficiency", severity: "warn", cost: 8, message: `SKILL.md body is ${bodyLines} lines; keep it under 500 and move detail into reference files.`, file: "SKILL.md" });
183
+ if (bodyTokens > 5000)
184
+ add({ rule: "eff/body-tokens", pillar: "efficiency", severity: "warn", cost: Math.min(12, Math.round((bodyTokens - 5000) / 500) + 4), message: `~${bodyTokens} tokens load every time this triggers. Aim for under 5,000; split into reference files read on demand.`, file: "SKILL.md" });
185
+ else if (bodyTokens > 3000 && others.length === 0)
186
+ add({ rule: "eff/no-disclosure", pillar: "efficiency", severity: "info", cost: 3, message: `~${bodyTokens} tokens in one file. Long tables, examples or catalogues belong in reference files.`, file: "SKILL.md" });
187
+ // Depth: a handful of one-line rules restates what a capable agent already does.
188
+ if (bodyTokens < 120)
189
+ add({ rule: "eff/body-thin", pillar: "efficiency", severity: "warn", cost: 10, message: "The body is very thin. A skill should carry knowledge the agent doesn't already have.", file: "SKILL.md" });
190
+ else if (bodyTokens + referenceTokens < 450)
191
+ add({ rule: "eff/body-light", pillar: "efficiency", severity: "warn", cost: 6, message: `Only ~${bodyTokens + referenceTokens} tokens of guidance. Add the specifics an agent can't guess: thresholds, checklists, worked examples.`, file: "SKILL.md" });
192
+ for (const f of others.filter((f) => isText(f.path) && /\.md$/i.test(f.path))) {
193
+ const r = rel(f.path);
194
+ if (!referenced.has(r) && !/readme|license|changelog/i.test(r))
195
+ add({ rule: "eff/orphan-file", pillar: "efficiency", severity: "info", cost: 2, message: `${r} is never mentioned in SKILL.md, so the agent won't know to read it.`, file: r });
196
+ if (tokens(f.content) > 2500 && !/^#{1,3} /m.test(f.content.slice(0, 600)) && !/contents|toc/i.test(f.content.slice(0, 800)))
197
+ add({ rule: "eff/reference-toc", pillar: "efficiency", severity: "info", cost: 1, message: `${r} is long; open it with a heading or contents list so the agent can jump to what it needs.`, file: r });
198
+ if (/\/[^/]+\/[^/]+\/[^/]+\.md$/.test("/" + r))
199
+ add({ rule: "eff/nested-refs", pillar: "efficiency", severity: "info", cost: 1, message: `${r} is nested deeply; keep references one level below SKILL.md.`, file: r });
200
+ }
201
+ // ---- clarity ----------------------------------------------------------
202
+ const headings = body.match(/^#{1,3} .+$/gm) ?? [];
203
+ if (headings.length < 2 && bodyLines > 25)
204
+ add({ rule: "clarity/structure", pillar: "clarity", severity: "warn", cost: 5, message: "Few headings; break the body into sections (workflow, rules, output).", file: "SKILL.md" });
205
+ if (!/(^#{1,3} .*\b(examples?|samples?|before\s*(\/|and|&|vs\.?|→)\s*after|good\s*(\/|and|&|vs\.?)\s*bad|do\s*(\/|and|&)\s*don't)\b)|```|^\s*\|.*\|.*\|\s*$/im.test(body))
206
+ add({ rule: "clarity/examples", pillar: "clarity", severity: "warn", cost: 5, message: "No examples or code blocks. One concrete input → output example improves consistency more than extra rules.", file: "SKILL.md" });
207
+ if (!/^#{1,3} .*(output|deliver|return|format|result|report)/im.test(body) && !/\b(output|return|respond with|reply with|deliver)\b/i.test(body))
208
+ add({ rule: "clarity/output", pillar: "clarity", severity: "warn", cost: 4, message: "Doesn't say what the result should look like. Add an Output section.", file: "SKILL.md" });
209
+ if (bodyLines > 40 && !/^\s*(\d+[.)]|- \[ \]|#{1,3} .*(workflow|steps|process|procedure|how to|modes?))/im.test(body))
210
+ add({ rule: "clarity/workflow", pillar: "clarity", severity: "info", cost: 3, message: "No numbered workflow. Ordered steps make agents more reliable than prose.", file: "SKILL.md" });
211
+ const vague = body.match(VAGUE) ?? [];
212
+ if (vague.length >= 4)
213
+ add({ rule: "clarity/vague", pillar: "clarity", severity: "info", cost: Math.min(5, vague.length - 2), message: `${vague.length} vague phrases (${[...new Set(vague.map((v) => v.toLowerCase()))].slice(0, 4).join(", ")}). Replace with concrete thresholds.`, file: "SKILL.md" });
214
+ const todo = TODO_RE.exec(body);
215
+ if (todo)
216
+ add({ rule: "clarity/unfinished", pillar: "clarity", severity: "warn", cost: 4, message: `Unfinished marker "${todo[2]}".`, file: "SKILL.md", line: fm.bodyStart + lineOf(body, todo.index) - 1 });
217
+ // ---- security ---------------------------------------------------------
218
+ const links = new Set();
219
+ for (const f of files.filter((f) => isText(f.path))) {
220
+ const r = rel(f.path);
221
+ const lines = f.content.split(/\r?\n/);
222
+ const seen = new Set();
223
+ lines.forEach((ln, i) => {
224
+ for (const p of SECURITY) {
225
+ if (seen.has(p.rule + p.message))
226
+ continue;
227
+ if (p.re.test(ln)) {
228
+ seen.add(p.rule + p.message);
229
+ add({ rule: p.rule, pillar: "security", severity: p.severity, cost: p.cost, message: p.message, file: r, line: i + 1 });
230
+ }
231
+ }
232
+ if (HIDDEN.test(ln) && !seen.has("hidden")) {
233
+ seen.add("hidden");
234
+ add({ rule: "sec/hidden-unicode", pillar: "security", severity: "critical", cost: 25, message: "Invisible or bidirectional Unicode characters can hide instructions from reviewers.", file: r, line: i + 1 });
235
+ }
236
+ for (const u of ln.match(URL_RE) ?? [])
237
+ links.add(u.replace(/[.,;:]+$/, ""));
238
+ });
239
+ if (/<!--[\s\S]*?(instruction|ignore|assistant|system|agent|you must)[\s\S]*?-->/i.test(f.content))
240
+ add({ rule: "sec/hidden-comment", pillar: "security", severity: "warn", cost: 10, message: "HTML comment carries instructions the user won't see rendered.", file: r });
241
+ }
242
+ const external = [...links].filter((u) => !/^https?:\/\/(localhost|127\.0\.0\.1|example\.(com|org))/i.test(u));
243
+ if (external.some((u) => /\b(pastebin|ngrok|webhook\.site|requestbin|transfer\.sh|discord(app)?\.com\/api\/webhooks|bit\.ly|tinyurl)\b/i.test(u)))
244
+ add({ rule: "sec/suspicious-host", pillar: "security", severity: "critical", cost: 20, message: "Links to a paste, tunnel, webhook or shortener host often used for exfiltration.", file: "SKILL.md" });
245
+ else if (external.some((u) => /^http:\/\//i.test(u)))
246
+ add({ rule: "sec/plain-http", pillar: "security", severity: "info", cost: 2, message: "Links over plain http.", file: "SKILL.md" });
247
+ if (scripts && !/^#{1,3} .*(script|run|tool)/im.test(body) && !others.some((f) => /\.(py|sh|js|mjs|ts)$/.test(f.path) && body.includes(rel(f.path).split("/").pop())))
248
+ add({ rule: "sec/undocumented-script", pillar: "security", severity: "warn", cost: 5, message: "Bundles scripts that SKILL.md never explains. Say what each script does and when it runs.", file: "SKILL.md" });
249
+ return finish(name, description, findings, { files: files.length, bodyLines, bodyTokens, metaTokens, referenceTokens, scripts, links: external.slice(0, 50) });
250
+ }
251
+ function finish(name, description, findings, stats) {
252
+ const pillars = { spec: PILLAR_MAX, efficiency: PILLAR_MAX, clarity: PILLAR_MAX, security: PILLAR_MAX };
253
+ for (const f of findings)
254
+ pillars[f.pillar] = Math.max(0, pillars[f.pillar] - f.cost);
255
+ const score = Math.round(pillars.spec + pillars.efficiency + pillars.clarity + pillars.security);
256
+ const critical = findings.some((f) => f.severity === "critical");
257
+ const grade = critical ? "F" : score >= 90 ? "A" : score >= 80 ? "B" : score >= 70 ? "C" : score >= 60 ? "D" : "F";
258
+ const order = { critical: 0, warn: 1, info: 2 };
259
+ findings.sort((a, b) => order[a.severity] - order[b.severity] || b.cost - a.cost);
260
+ return { name, description, score, grade, pillars, findings, stats };
261
+ }
@@ -0,0 +1,50 @@
1
+ import { existsSync, lstatSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join, relative, resolve } from "node:path";
3
+ import { scanSkill } from "./scan.js";
4
+ export const DEFAULT_SCAN_DIRS = [".claude/skills", ".agents/skills"];
5
+ function walk(dir, base, out) {
6
+ for (const name of readdirSync(dir)) {
7
+ if (name === "node_modules" || name.startsWith("."))
8
+ continue;
9
+ const p = join(dir, name);
10
+ const st = lstatSync(p);
11
+ if (st.isSymbolicLink())
12
+ continue;
13
+ if (st.isDirectory())
14
+ walk(p, base, out);
15
+ else if (st.size < 2_000_000)
16
+ out.push({ path: relative(base, p).replace(/\\/g, "/"), content: readFileSync(p, "utf8") });
17
+ }
18
+ }
19
+ /** A skill folder itself, or the skill folders directly inside a folder. */
20
+ export function skillDirs(root) {
21
+ if (!existsSync(root) || !lstatSync(root).isDirectory())
22
+ return [];
23
+ if (existsSync(join(root, "SKILL.md")))
24
+ return [root];
25
+ return readdirSync(root)
26
+ .filter((n) => !n.startsWith("."))
27
+ .map((n) => join(root, n))
28
+ .filter((p) => {
29
+ const st = lstatSync(p);
30
+ return st.isDirectory() && !st.isSymbolicLink() && existsSync(join(p, "SKILL.md"));
31
+ });
32
+ }
33
+ export function scanDirs(roots, cwd) {
34
+ const seen = new Set();
35
+ const results = [];
36
+ for (const root of roots) {
37
+ for (const dir of skillDirs(resolve(cwd, root))) {
38
+ if (seen.has(dir))
39
+ continue;
40
+ seen.add(dir);
41
+ const files = [];
42
+ walk(dir, join(dir, ".."), files);
43
+ results.push({ dir: relative(cwd, dir) || ".", report: scanSkill(files) });
44
+ }
45
+ }
46
+ return results;
47
+ }
48
+ export function isFailing(r, min) {
49
+ return r.report.grade === "F" || r.report.score < min;
50
+ }
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "proskills-cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Install ProSkills agent skills in one command and scan any SKILL.md folder in CI.",
5
+ "keywords": [
6
+ "agent-skills",
7
+ "skill.md",
8
+ "claude-code",
9
+ "cursor",
10
+ "codex",
11
+ "gemini-cli",
12
+ "cli"
13
+ ],
14
+ "homepage": "https://proskills.dev",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/yungprimer/proskills.git",
18
+ "directory": "cli"
19
+ },
20
+ "bugs": {
21
+ "url": "https://github.com/yungprimer/proskills/issues"
22
+ },
23
+ "author": "ProSkills (https://proskills.dev)",
24
+ "license": "MIT",
25
+ "type": "module",
26
+ "bin": {
27
+ "proskills": "dist/index.js"
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "README.md"
32
+ ],
33
+ "engines": {
34
+ "node": ">=18"
35
+ },
36
+ "scripts": {
37
+ "prebuild": "node scripts/copy-scan.mjs",
38
+ "build": "tsc -p tsconfig.json",
39
+ "test": "node --test test/cli.test.mjs",
40
+ "prepublishOnly": "npm run build && npm test"
41
+ },
42
+ "dependencies": {
43
+ "jszip": "^3.10.1"
44
+ },
45
+ "devDependencies": {
46
+ "@types/node": "^20",
47
+ "typescript": "^5"
48
+ }
49
+ }