skillprint 0.1.0__py3-none-any.whl

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.
skillprint/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """skillprint: see what an agent skill pack loads and runs before you install it."""
2
+ __version__ = "0.1.0"
skillprint/cli.py ADDED
@@ -0,0 +1,128 @@
1
+ """Command line: skillprint <path-or-git-url> [--json] [--badge] [--details]"""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ from urllib.parse import quote
11
+
12
+ from . import __version__
13
+ from .scan import scan, Report, HOOK_EVENTS_EVERY_ACTION
14
+
15
+ LABELS = {
16
+ "shell_exec": "runs shell commands",
17
+ "pipe_to_shell": "pipes a download into a shell",
18
+ "destructive": "destructive commands (rm -rf, force push)",
19
+ "secrets_access": "reads credential files",
20
+ "env_secrets": "reads secret env vars",
21
+ "auto_install": "installs packages",
22
+ "hidden_unicode": "hidden Unicode characters",
23
+ }
24
+ COLORS = {"none": "brightgreen", "low": "green", "medium": "yellow", "high": "orange"}
25
+
26
+
27
+ def _fmt(n: int) -> str:
28
+ return f"{n:,}"
29
+
30
+
31
+ def badge(r: Report) -> str:
32
+ biggest = max((len(v) for v in r.hooks_by_config().values()), default=0)
33
+ msg = f"{r.footprint} footprint · {_fmt(r.upfront_tokens)} tokens · {biggest} hooks"
34
+ url = f"https://img.shields.io/badge/skillprint-{quote(msg.replace('-', '--'))}-{COLORS[r.footprint]}"
35
+ return f"[![skillprint]({url})](https://github.com/mekala540/skillprint)"
36
+
37
+
38
+ def text_report(r: Report, details: bool) -> str:
39
+ out = []
40
+ out.append(f"skillprint {__version__} · {r.root}")
41
+ out.append("")
42
+ out.append(f" Footprint {r.footprint.upper()}")
43
+ copies = f" ({_fmt(r.skill_files)} SKILL.md files incl. copies)" if r.skill_files != len(r.skills) else ""
44
+ out.append(f" Skills {_fmt(len(r.skills))}{copies}")
45
+ out.append(f" Loaded up front ≈{_fmt(r.upfront_tokens)} tokens (names + descriptions, every session)")
46
+ out.append(f" Loaded when used ≈{_fmt(r.on_use_tokens)} tokens (all skill bodies)")
47
+ out.append(f" Scripts {_fmt(len(r.scripts))}")
48
+ groups = r.hooks_by_config()
49
+ if not groups:
50
+ out.append(" Auto-run hooks 0")
51
+ for i, (src, hs) in enumerate(groups.items()):
52
+ every = sum(1 for h in hs if h.event.lower() in HOOK_EVENTS_EVERY_ACTION)
53
+ events = ", ".join(sorted({h.event for h in hs}))
54
+ head = " Auto-run hooks " if i == 0 else " "
55
+ tail = f", {every} on every prompt or tool call" if every else ""
56
+ out.append(f"{head}{len(hs):>3} in {src}{tail}")
57
+ out.append(f" [{events}]")
58
+ counts = r.finding_counts()
59
+ out.append("")
60
+ out.append(" What its code can do (scripts, hook commands, code blocks in skills):")
61
+ if not counts:
62
+ out.append(" nothing flagged")
63
+ for k, v in sorted(counts.items(), key=lambda kv: -kv[1]):
64
+ out.append(f" {v:>5} {LABELS.get(k, k)}")
65
+ if r.network_domains:
66
+ top = ", ".join(list(dict(sorted(r.network_domains.items(), key=lambda kv: -kv[1])))[:6])
67
+ out.append(f" domains: {top}")
68
+ ignored = sum(r.doc_mentions.values())
69
+ if ignored:
70
+ out.append(f" ({_fmt(ignored)} pattern matches in docs and prose ignored: they describe, they don't run)")
71
+ if r.test_files:
72
+ out.append(f" ({_fmt(r.test_files)} test files skipped: they don't ship to the agent)")
73
+ if details:
74
+ out.append("")
75
+ out.append(" Hooks:")
76
+ for h in r.hooks:
77
+ out.append(f" {h.event:<20} {h.command[:90]} ({h.source})")
78
+ out.append(" Findings:")
79
+ for f in r.findings:
80
+ out.append(f" {f.kind:<15} {f.path}:{f.line} {f.excerpt}")
81
+ out.append(" Largest skills (tokens up front / when used):")
82
+ for s in sorted(r.skills, key=lambda s: -s.body_tokens)[:10]:
83
+ out.append(f" {s.description_tokens:>5} / {s.body_tokens:>6} {s.name}")
84
+ out.append("")
85
+ out.append(" Counts describe capability, not intent. Token figures are estimates (chars / 4).")
86
+ return "\n".join(out)
87
+
88
+
89
+ def main(argv: list[str] | None = None) -> int:
90
+ p = argparse.ArgumentParser(prog="skillprint", description="Measure what an agent skill pack loads and runs.")
91
+ p.add_argument("target", help="path to a skill pack, or a git URL")
92
+ p.add_argument("--json", action="store_true", help="machine-readable output")
93
+ p.add_argument("--badge", action="store_true", help="print a README badge")
94
+ p.add_argument("--details", action="store_true", help="list every hook, finding and the largest skills")
95
+ p.add_argument("--fail-on", choices=["medium", "high"], help="exit 1 if footprint is at least this (for CI)")
96
+ p.add_argument("--version", action="version", version=__version__)
97
+ a = p.parse_args(argv)
98
+
99
+ tmp = None
100
+ target = a.target
101
+ if target.startswith(("https://", "git@")):
102
+ tmp = tempfile.mkdtemp(prefix="skillprint-")
103
+ res = subprocess.run(["git", "clone", "--depth", "1", "-q", target, tmp], capture_output=True, text=True)
104
+ if res.returncode:
105
+ print(f"could not clone {target}: {res.stderr.strip()}", file=sys.stderr)
106
+ return 2
107
+ target = tmp
108
+ try:
109
+ r = scan(target)
110
+ if tmp:
111
+ r.root = a.target
112
+ if a.json:
113
+ print(json.dumps(r.to_dict(), indent=2))
114
+ elif a.badge:
115
+ print(badge(r))
116
+ else:
117
+ print(text_report(r, a.details))
118
+ order = ["none", "low", "medium", "high"]
119
+ if a.fail_on and order.index(r.footprint) >= order.index(a.fail_on):
120
+ return 1
121
+ return 0
122
+ finally:
123
+ if tmp:
124
+ shutil.rmtree(tmp, ignore_errors=True)
125
+
126
+
127
+ if __name__ == "__main__":
128
+ sys.exit(main())
skillprint/scan.py ADDED
@@ -0,0 +1,289 @@
1
+ """Core scanner: walks a skill pack and measures what it loads and what it runs."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import re
7
+ from dataclasses import dataclass, field, asdict
8
+ from pathlib import Path
9
+
10
+ SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", "dist", "build"}
11
+ BINARY_EXT = {
12
+ "png", "jpg", "jpeg", "gif", "webp", "ico", "svg", "pdf", "zip", "gz", "tgz",
13
+ "woff", "woff2", "ttf", "otf", "mp4", "mov", "mp3", "wav", "so", "dylib", "dll",
14
+ "exe", "bin", "lock",
15
+ }
16
+ SCRIPT_EXT = {"sh", "bash", "zsh", "py", "js", "mjs", "cjs", "ts", "ps1", "cmd", "bat", "rb", "pl"}
17
+ CONFIG_EXT = {"json", "yaml", "yml", "toml"}
18
+ MAX_BYTES = 1_000_000
19
+
20
+ # Hook events that fire on every prompt or tool call (Claude Code, Cursor, Codex naming).
21
+ HOOK_EVENTS_EVERY_ACTION = {
22
+ "pretooluse", "posttooluse", "posttoolusefailure", "userpromptsubmit",
23
+ "beforeshellexecution", "aftershellexecution", "beforemcpexecution", "aftermcpexecution",
24
+ "beforereadfile", "afterfileedit", "beforesubmitprompt", "beforetabfileread", "aftertabfileedit",
25
+ }
26
+ TEST_PATH = re.compile(r"(^|/)(tests?|__tests__|spec|fixtures?)(/|$)|(\.test\.|\.spec\.|_test\.py$|(^|/)test_[^/]*\.py$)")
27
+ PLACEHOLDER_DOMAIN = re.compile(r"(^|\.)(example\.(com|org|net|test)|localhost|w3\.org|schema\.org|test|invalid)$")
28
+
29
+ # Capability patterns. They describe what code CAN do, not intent.
30
+ PATTERNS = {
31
+ "shell_exec": r"(subprocess\.|child_process|\bexecSync\(|\bexec\(|os\.system\(|\bspawn(Sync)?\(|\bbash -c\b)",
32
+ "pipe_to_shell": r"(curl|wget)\b[^\n|]*\|\s*(sudo\s+)?(ba|z)?sh\b",
33
+ "destructive": r"(rm\s+-[a-z]*r[a-z]*f|rm\s+-[a-z]*f[a-z]*r|git\s+push\s+(-f|--force)|git\s+reset\s+--hard|DROP\s+TABLE|mkfs\.)",
34
+ "secrets_access": r"(~/\.ssh|\.ssh/id_|~/\.aws|\.aws/credentials|security\s+find-(generic|internet)-password|SecItemCopyMatching|\bkeytar\b|\.netrc\b|~/\.npmrc|~/\.pypirc)",
35
+ "env_secrets": r"(process\.env\.[A-Z_]*(KEY|TOKEN|SECRET)|os\.environ(\.get)?\(?\[?['\"][A-Z_]*(KEY|TOKEN|SECRET))",
36
+ "auto_install": r"(\bnpm\s+(i|install)\b|\bpip3?\s+install\b|\bnpx\s+-y\b|\bbrew\s+install\b|\bcargo\s+install\b|\bgo\s+install\b)",
37
+ }
38
+ HIDDEN_UNICODE = re.compile("[​‌‍⁠‪-‮⁦-⁩]")
39
+ URL_RE = re.compile(r"https?://([A-Za-z0-9.-]+\.[A-Za-z]{2,})")
40
+ FRONTMATTER_RE = re.compile(r"\A---\s*\n(.*?)\n---\s*\n", re.S)
41
+
42
+
43
+ def est_tokens(text: str) -> int:
44
+ """Rough token estimate (characters / 4). Good enough to compare packs."""
45
+ return (len(text) + 3) // 4
46
+
47
+
48
+ @dataclass
49
+ class Skill:
50
+ name: str
51
+ path: str
52
+ description_tokens: int
53
+ body_tokens: int
54
+
55
+
56
+ @dataclass
57
+ class Hook:
58
+ event: str
59
+ command: str
60
+ source: str
61
+
62
+
63
+ @dataclass
64
+ class Finding:
65
+ kind: str
66
+ path: str
67
+ line: int
68
+ excerpt: str
69
+
70
+
71
+ @dataclass
72
+ class Report:
73
+ root: str
74
+ files: int = 0
75
+ skills: list[Skill] = field(default_factory=list)
76
+ skill_files: int = 0
77
+ scripts: list[str] = field(default_factory=list)
78
+ hooks: list[Hook] = field(default_factory=list)
79
+ findings: list[Finding] = field(default_factory=list)
80
+ doc_mentions: dict[str, int] = field(default_factory=dict)
81
+ test_files: int = 0
82
+ network_domains: dict[str, int] = field(default_factory=dict)
83
+
84
+ # ---- derived numbers -------------------------------------------------
85
+ @property
86
+ def upfront_tokens(self) -> int:
87
+ return sum(s.description_tokens for s in self.skills)
88
+
89
+ @property
90
+ def on_use_tokens(self) -> int:
91
+ return sum(s.body_tokens for s in self.skills)
92
+
93
+ @property
94
+ def hooks_every_action(self) -> int:
95
+ return sum(1 for h in self.hooks if h.event.lower() in HOOK_EVENTS_EVERY_ACTION)
96
+
97
+ def hooks_by_config(self) -> dict[str, list[Hook]]:
98
+ """Packs often ship one hook config per tool; a user installs one of them."""
99
+ out: dict[str, list[Hook]] = {}
100
+ for h in self.hooks:
101
+ out.setdefault(h.source, []).append(h)
102
+ return dict(sorted(out.items(), key=lambda kv: -len(kv[1])))
103
+
104
+ def finding_counts(self) -> dict[str, int]:
105
+ out: dict[str, int] = {}
106
+ for f in self.findings:
107
+ out[f.kind] = out.get(f.kind, 0) + 1
108
+ return out
109
+
110
+ @property
111
+ def footprint(self) -> str:
112
+ """none < low < medium < high, from what runs without being asked."""
113
+ c = self.finding_counts()
114
+ if self.hooks_every_action or c.get("pipe_to_shell") or c.get("hidden_unicode"):
115
+ return "high"
116
+ if self.hooks or self.scripts:
117
+ return "medium"
118
+ if self.skills:
119
+ return "low"
120
+ return "none"
121
+
122
+ def to_dict(self) -> dict:
123
+ return {
124
+ "root": self.root,
125
+ "footprint": self.footprint,
126
+ "files": self.files,
127
+ "skills_unique": len(self.skills),
128
+ "skill_files": self.skill_files,
129
+ "upfront_tokens": self.upfront_tokens,
130
+ "on_use_tokens": self.on_use_tokens,
131
+ "scripts": len(self.scripts),
132
+ "hooks": len(self.hooks),
133
+ "hooks_every_action": self.hooks_every_action,
134
+ "hook_events": sorted({h.event for h in self.hooks}),
135
+ "hooks_by_config": {k: len(v) for k, v in self.hooks_by_config().items()},
136
+ "test_files_skipped": self.test_files,
137
+ "findings": self.finding_counts(),
138
+ "doc_mentions_ignored": self.doc_mentions,
139
+ "network_domains": dict(sorted(self.network_domains.items(), key=lambda kv: -kv[1])),
140
+ "skills": [asdict(s) for s in self.skills],
141
+ "hook_list": [asdict(h) for h in self.hooks],
142
+ "finding_list": [asdict(f) for f in self.findings],
143
+ }
144
+
145
+
146
+ # ---- helpers -------------------------------------------------------------
147
+
148
+ def _iter_files(root: Path):
149
+ for dirpath, dirnames, filenames in os.walk(root):
150
+ dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
151
+ for name in filenames:
152
+ yield Path(dirpath) / name
153
+
154
+
155
+ def _read_text(path: Path) -> str | None:
156
+ if path.suffix.lower().lstrip(".") in BINARY_EXT:
157
+ return None
158
+ try:
159
+ if path.stat().st_size > MAX_BYTES:
160
+ return None
161
+ raw = path.read_bytes()
162
+ except OSError:
163
+ return None
164
+ if b"\x00" in raw[:4096]:
165
+ return None
166
+ return raw.decode("utf-8", errors="ignore")
167
+
168
+
169
+ def _is_script(path: Path, text: str) -> bool:
170
+ return path.suffix.lower().lstrip(".") in SCRIPT_EXT or text.startswith("#!")
171
+
172
+
173
+ def _parse_frontmatter(text: str) -> dict[str, str]:
174
+ m = FRONTMATTER_RE.match(text)
175
+ if not m:
176
+ return {}
177
+ fields: dict[str, str] = {}
178
+ key = None
179
+ for line in m.group(1).splitlines():
180
+ km = re.match(r"^([A-Za-z_][\w-]*):\s*(.*)$", line)
181
+ if km:
182
+ key = km.group(1).lower()
183
+ fields[key] = km.group(2).strip()
184
+ elif key and line.startswith((" ", "\t")):
185
+ fields[key] += " " + line.strip()
186
+ return fields
187
+
188
+
189
+ def _rank(path: str) -> tuple:
190
+ """Prefer the canonical copy of a skill: not under docs/, then the shortest path."""
191
+ return (path.startswith("docs"), path.count("/"), len(path))
192
+
193
+
194
+ def _code_blocks(markdown: str) -> str:
195
+ """Only fenced code in a SKILL.md is something the agent may run."""
196
+ return "\n".join(re.findall(r"```[^\n]*\n(.*?)```", markdown, re.S))
197
+
198
+
199
+ def _collect_hooks(data, source: str) -> list[Hook]:
200
+ hooks: list[Hook] = []
201
+ block = data.get("hooks") if isinstance(data, dict) else None
202
+ if not isinstance(block, dict):
203
+ return hooks
204
+ for event, entries in block.items():
205
+ if not isinstance(entries, list):
206
+ continue
207
+ for entry in entries:
208
+ inner = entry.get("hooks") if isinstance(entry, dict) else None
209
+ cmds = inner if isinstance(inner, list) else [entry]
210
+ for c in cmds:
211
+ cmd = c.get("command", "") if isinstance(c, dict) else str(c)
212
+ hooks.append(Hook(event=event, command=str(cmd)[:200], source=source))
213
+ return hooks
214
+
215
+
216
+ def _scan_text(rel: str, text: str, report: Report, as_findings: bool) -> None:
217
+ for kind, pat in PATTERNS.items():
218
+ for m in re.finditer(pat, text, re.I):
219
+ if as_findings:
220
+ line = text.count("\n", 0, m.start()) + 1
221
+ excerpt = text.splitlines()[line - 1].strip()[:140] if text else ""
222
+ report.findings.append(Finding(kind, rel, line, excerpt))
223
+ else:
224
+ report.doc_mentions[kind] = report.doc_mentions.get(kind, 0) + 1
225
+ if as_findings:
226
+ for m in HIDDEN_UNICODE.finditer(text):
227
+ line = text.count("\n", 0, m.start()) + 1
228
+ report.findings.append(Finding("hidden_unicode", rel, line, f"U+{ord(m.group()):04X}"))
229
+ break
230
+ for m in URL_RE.finditer(text):
231
+ d = m.group(1).lower()
232
+ if PLACEHOLDER_DOMAIN.search(d):
233
+ continue
234
+ report.network_domains[d] = report.network_domains.get(d, 0) + 1
235
+
236
+
237
+ # ---- main entry ----------------------------------------------------------
238
+
239
+ def scan(root: str | os.PathLike) -> Report:
240
+ root = Path(root).resolve()
241
+ report = Report(root=str(root))
242
+ seen_skills: dict[str, Skill] = {}
243
+
244
+ for path in _iter_files(root):
245
+ rel = str(path.relative_to(root))
246
+ text = _read_text(path)
247
+ report.files += 1
248
+ if text is None:
249
+ continue
250
+
251
+ if path.name == "SKILL.md":
252
+ report.skill_files += 1
253
+ fm = _parse_frontmatter(text)
254
+ name = fm.get("name") or path.parent.name
255
+ desc = fm.get("description", "")
256
+ body = FRONTMATTER_RE.sub("", text, count=1)
257
+ key = name.strip().lower()
258
+ cand = Skill(name, rel, est_tokens(f"{name}: {desc}"), est_tokens(body))
259
+ prev = seen_skills.get(key) # copies for other tools or languages count once
260
+ if prev is None or _rank(cand.path) < _rank(prev.path):
261
+ seen_skills[key] = cand
262
+ _scan_text(rel, _code_blocks(text), report, as_findings=True)
263
+ _scan_text(rel, re.sub(r"```.*?```", "", text, flags=re.S), report, as_findings=False)
264
+ continue
265
+
266
+ if TEST_PATH.search(rel.replace(os.sep, "/")):
267
+ report.test_files += 1 # tests are not installed into the agent
268
+ continue
269
+
270
+ if _is_script(path, text):
271
+ report.scripts.append(rel)
272
+ _scan_text(rel, text, report, as_findings=True)
273
+ continue
274
+
275
+ if path.suffix.lower().lstrip(".") == "json" and "hooks" in text:
276
+ try:
277
+ new = _collect_hooks(json.loads(text), rel)
278
+ report.hooks.extend(new)
279
+ for h in new: # a hook command runs without asking, so it counts
280
+ _scan_text(rel, h.command, report, as_findings=True)
281
+ except ValueError:
282
+ pass
283
+ continue
284
+
285
+ if path.suffix.lower().lstrip(".") not in CONFIG_EXT:
286
+ _scan_text(rel, text, report, as_findings=False) # docs: counted, not flagged
287
+
288
+ report.skills = sorted(seen_skills.values(), key=lambda s: s.path)
289
+ return report
@@ -0,0 +1,110 @@
1
+ Metadata-Version: 2.4
2
+ Name: skillprint
3
+ Version: 0.1.0
4
+ Summary: See what an agent skill pack loads and runs before you install it.
5
+ Author: mekala
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/mekala540/skillprint
8
+ Project-URL: Issues, https://github.com/mekala540/skillprint/issues
9
+ Keywords: ai-agents,agent-skills,claude-code,cursor,codex,security,mcp
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Topic :: Security
13
+ Classifier: Topic :: Software Development :: Quality Assurance
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Dynamic: license-file
18
+
19
+ # skillprint
20
+
21
+ [![tests](https://github.com/mekala540/skillprint/actions/workflows/tests.yml/badge.svg)](https://github.com/mekala540/skillprint/actions/workflows/tests.yml) ![license](https://img.shields.io/badge/license-MIT-blue) ![python](https://img.shields.io/badge/python-3.9%2B-blue)
22
+
23
+ **See what an agent skill pack loads and runs — before you install it.**
24
+
25
+ Skill packs for Claude Code, Cursor, Codex and OpenClaw now collect hundreds of thousands of stars. Stars tell you a pack is popular. They don't tell you that it adds 25,000 tokens to every session, or registers 27 hooks that run code before your agent's tool calls.
26
+
27
+ `skillprint` reads a pack and reports:
28
+
29
+ - **Context cost** — tokens loaded up front every session (skill names and descriptions) and tokens loaded when skills are used
30
+ - **Auto-run hooks** — what runs without you asking, grouped per tool config, and how many fire on every prompt or tool call
31
+ - **What its code can do** — shell commands, destructive commands, package installs, credential and secret access, download-into-shell, hidden Unicode, network domains
32
+ - **A footprint level** — `low` (instructions only), `medium` (ships scripts or session hooks), `high` (hooks on every action, download-into-shell, or hidden characters)
33
+
34
+ It is not a malware scanner. Snyk Agent Scan, Mondoo, Socket and VirusTotal already do that. skillprint answers a different question: *what am I actually signing up for?*
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pipx install skillprint # or: pip install skillprint
40
+ ```
41
+
42
+ No dependencies. Python 3.9+.
43
+
44
+ ## Use
45
+
46
+ ```bash
47
+ skillprint ./my-skills # a local folder
48
+ skillprint https://github.com/obra/superpowers # any git URL
49
+ skillprint ./my-skills --details # every hook, finding, and the largest skills
50
+ skillprint ./my-skills --json # machine-readable
51
+ skillprint ./my-skills --badge # README badge markdown
52
+ skillprint ./my-skills --fail-on high # exit 1 in CI if footprint is high
53
+ ```
54
+
55
+ ## What the biggest packs look like (scanned 11 Oct 2026)
56
+
57
+ | Pack | Stars | Footprint | Skills | Tokens up front | Auto-run hooks (main config) | Scripts |
58
+ | --- | --- | --- | --- | --- | --- | --- |
59
+ | [multica-ai/andrej-karpathy-skills](https://github.com/multica-ai/andrej-karpathy-skills) | 212k | low | 1 | ≈60 | 0 | 0 |
60
+ | [obra/superpowers](https://github.com/obra/superpowers) | 285k | medium | 15 | ≈670 | 1 (session start) | 23 |
61
+ | [mattpocock/skills](https://github.com/mattpocock/skills) | 260k | medium | 38 | ≈1,500 | 0 | 7 |
62
+ | [affaan-m/ECC](https://github.com/affaan-m/ECC) | 257k | high | 314 | ≈25,600 | 27, of which 15 fire on every prompt or tool call | 736 |
63
+
64
+ None of these showed signs of malice. They differ by 400× in context cost and from zero to dozens of auto-run hooks — which is exactly what a star count hides.
65
+
66
+ ## How it decides
67
+
68
+ - **Skills** are found by `SKILL.md`. Copies of the same skill (per-tool folders, translations) count once; the canonical copy is preferred.
69
+ - **Up-front tokens** = name + description of each skill, which agents load into every session so they know what's available. **On-use tokens** = the skill bodies. Both are estimated as characters ÷ 4.
70
+ - **Hooks** are read from any JSON config with a `hooks` block (Claude Code, Cursor, Codex, Qoder formats), grouped by file, since you install one tool's config.
71
+ - **Capability patterns** are matched only where code can run: scripts, hook commands, and fenced code blocks inside `SKILL.md`. Mentions in docs and prose are counted separately and ignored — a security guide that says "never `curl | bash`" is not a finding. Test files are skipped.
72
+ - Counts describe **capability, not intent**. A `rm -rf "$TMP_DIR"` in a cleanup script is normal. Read the `--details` output before judging.
73
+
74
+ ## Use it in CI
75
+
76
+ ```yaml
77
+ # .github/workflows/skillprint.yml
78
+ name: skillprint
79
+ on: [pull_request]
80
+ jobs:
81
+ footprint:
82
+ runs-on: ubuntu-latest
83
+ steps:
84
+ - uses: actions/checkout@v4
85
+ - uses: actions/setup-python@v5
86
+ with: { python-version: "3.12" }
87
+ - run: pip install skillprint
88
+ - run: skillprint . --details --fail-on high
89
+ ```
90
+
91
+ ## Roadmap
92
+
93
+ - [x] Footprint: context cost, hooks, capabilities, badge, CI mode
94
+ - [ ] Exact token counts per model tokenizer
95
+ - [ ] Diff mode: what changed between two versions of a pack
96
+ - [ ] **Lift**: run a fixed task suite with and without a skill, per model, and report whether it actually helps
97
+ - [ ] Public ratings page for the most-starred skills
98
+ - [ ] Pull in Snyk / Socket / VirusTotal verdicts where they exist
99
+
100
+ ## Contributing
101
+
102
+ Found a false positive or a pattern we miss? Open an issue with the file and line. Rules live in `skillprint/scan.py` and every rule has a test in `tests/`.
103
+
104
+ ```bash
105
+ pip install -e . pytest && pytest
106
+ ```
107
+
108
+ ## License
109
+
110
+ MIT
@@ -0,0 +1,9 @@
1
+ skillprint/__init__.py,sha256=lfnl6AVXyiUqALLjFfF23vj-u4Bjrj1eoSf5VSQogQg,107
2
+ skillprint/cli.py,sha256=ku4rNV2Wpj6-AAIAd-1InHqhH5tfB972NPHjf1xUrLA,5564
3
+ skillprint/scan.py,sha256=PIbcn9qXmVQW9ahRHU1bRPS7SspQtTdiAsor5SoSyRI,11153
4
+ skillprint-0.1.0.dist-info/licenses/LICENSE,sha256=fIVbYi2zoWWvwEMmb8G78CyWM2gH1G4kL0SwDlCVq9w,1063
5
+ skillprint-0.1.0.dist-info/METADATA,sha256=RdA8Cba1KLkb5yKx9ny33invROfojx5fKWlGsFOs5Yo,5534
6
+ skillprint-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
7
+ skillprint-0.1.0.dist-info/entry_points.txt,sha256=d0K5lORnBs4YOBfuOlnii21AbsTyLZUMRlyus6vJ6zo,51
8
+ skillprint-0.1.0.dist-info/top_level.txt,sha256=POfof_bwVCLx5T3E_F4yPVULdMoKpYbMlN6ZDZCh8sM,11
9
+ skillprint-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ skillprint = skillprint.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 mekala
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ skillprint