skillprint 0.1.0__tar.gz

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.
@@ -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,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,92 @@
1
+ # skillprint
2
+
3
+ [![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)
4
+
5
+ **See what an agent skill pack loads and runs — before you install it.**
6
+
7
+ 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.
8
+
9
+ `skillprint` reads a pack and reports:
10
+
11
+ - **Context cost** — tokens loaded up front every session (skill names and descriptions) and tokens loaded when skills are used
12
+ - **Auto-run hooks** — what runs without you asking, grouped per tool config, and how many fire on every prompt or tool call
13
+ - **What its code can do** — shell commands, destructive commands, package installs, credential and secret access, download-into-shell, hidden Unicode, network domains
14
+ - **A footprint level** — `low` (instructions only), `medium` (ships scripts or session hooks), `high` (hooks on every action, download-into-shell, or hidden characters)
15
+
16
+ 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?*
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pipx install skillprint # or: pip install skillprint
22
+ ```
23
+
24
+ No dependencies. Python 3.9+.
25
+
26
+ ## Use
27
+
28
+ ```bash
29
+ skillprint ./my-skills # a local folder
30
+ skillprint https://github.com/obra/superpowers # any git URL
31
+ skillprint ./my-skills --details # every hook, finding, and the largest skills
32
+ skillprint ./my-skills --json # machine-readable
33
+ skillprint ./my-skills --badge # README badge markdown
34
+ skillprint ./my-skills --fail-on high # exit 1 in CI if footprint is high
35
+ ```
36
+
37
+ ## What the biggest packs look like (scanned 11 Oct 2026)
38
+
39
+ | Pack | Stars | Footprint | Skills | Tokens up front | Auto-run hooks (main config) | Scripts |
40
+ | --- | --- | --- | --- | --- | --- | --- |
41
+ | [multica-ai/andrej-karpathy-skills](https://github.com/multica-ai/andrej-karpathy-skills) | 212k | low | 1 | ≈60 | 0 | 0 |
42
+ | [obra/superpowers](https://github.com/obra/superpowers) | 285k | medium | 15 | ≈670 | 1 (session start) | 23 |
43
+ | [mattpocock/skills](https://github.com/mattpocock/skills) | 260k | medium | 38 | ≈1,500 | 0 | 7 |
44
+ | [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 |
45
+
46
+ 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.
47
+
48
+ ## How it decides
49
+
50
+ - **Skills** are found by `SKILL.md`. Copies of the same skill (per-tool folders, translations) count once; the canonical copy is preferred.
51
+ - **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.
52
+ - **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.
53
+ - **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.
54
+ - Counts describe **capability, not intent**. A `rm -rf "$TMP_DIR"` in a cleanup script is normal. Read the `--details` output before judging.
55
+
56
+ ## Use it in CI
57
+
58
+ ```yaml
59
+ # .github/workflows/skillprint.yml
60
+ name: skillprint
61
+ on: [pull_request]
62
+ jobs:
63
+ footprint:
64
+ runs-on: ubuntu-latest
65
+ steps:
66
+ - uses: actions/checkout@v4
67
+ - uses: actions/setup-python@v5
68
+ with: { python-version: "3.12" }
69
+ - run: pip install skillprint
70
+ - run: skillprint . --details --fail-on high
71
+ ```
72
+
73
+ ## Roadmap
74
+
75
+ - [x] Footprint: context cost, hooks, capabilities, badge, CI mode
76
+ - [ ] Exact token counts per model tokenizer
77
+ - [ ] Diff mode: what changed between two versions of a pack
78
+ - [ ] **Lift**: run a fixed task suite with and without a skill, per model, and report whether it actually helps
79
+ - [ ] Public ratings page for the most-starred skills
80
+ - [ ] Pull in Snyk / Socket / VirusTotal verdicts where they exist
81
+
82
+ ## Contributing
83
+
84
+ 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/`.
85
+
86
+ ```bash
87
+ pip install -e . pytest && pytest
88
+ ```
89
+
90
+ ## License
91
+
92
+ MIT
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "skillprint"
7
+ version = "0.1.0"
8
+ description = "See what an agent skill pack loads and runs before you install it."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "mekala" }]
12
+ requires-python = ">=3.9"
13
+ dependencies = []
14
+ keywords = ["ai-agents", "agent-skills", "claude-code", "cursor", "codex", "security", "mcp"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Topic :: Security",
19
+ "Topic :: Software Development :: Quality Assurance",
20
+ ]
21
+
22
+ [project.scripts]
23
+ skillprint = "skillprint.cli:main"
24
+
25
+ [tool.setuptools]
26
+ packages = ["skillprint"]
27
+
28
+ [project.urls]
29
+ Homepage = "https://github.com/mekala540/skillprint"
30
+ Issues = "https://github.com/mekala540/skillprint/issues"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ """skillprint: see what an agent skill pack loads and runs before you install it."""
2
+ __version__ = "0.1.0"
@@ -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())
@@ -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,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ skillprint/__init__.py
5
+ skillprint/cli.py
6
+ skillprint/scan.py
7
+ skillprint.egg-info/PKG-INFO
8
+ skillprint.egg-info/SOURCES.txt
9
+ skillprint.egg-info/dependency_links.txt
10
+ skillprint.egg-info/entry_points.txt
11
+ skillprint.egg-info/top_level.txt
12
+ tests/test_scan.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ skillprint = skillprint.cli:main
@@ -0,0 +1 @@
1
+ skillprint
@@ -0,0 +1,122 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ from skillprint.scan import scan
5
+ from skillprint.cli import main
6
+
7
+
8
+ def write(root: Path, rel: str, text: str) -> None:
9
+ p = root / rel
10
+ p.parent.mkdir(parents=True, exist_ok=True)
11
+ p.write_text(text, encoding="utf-8")
12
+
13
+
14
+ SKILL = "---\nname: {n}\ndescription: {d}\n---\n\n# {n}\n\nDo the thing.\n"
15
+
16
+
17
+ def test_instructions_only_pack_is_low(tmp_path):
18
+ write(tmp_path, "skills/a/SKILL.md", SKILL.format(n="a", d="Write tests first"))
19
+ r = scan(tmp_path)
20
+ assert len(r.skills) == 1
21
+ assert r.footprint == "low"
22
+ assert r.upfront_tokens > 0 and not r.findings
23
+
24
+
25
+ def test_copies_of_a_skill_count_once(tmp_path):
26
+ for d in ("skills", ".cursor/skills", "docs/es/skills"):
27
+ write(tmp_path, f"{d}/a/SKILL.md", SKILL.format(n="a", d="Same skill"))
28
+ r = scan(tmp_path)
29
+ assert r.skill_files == 3 and len(r.skills) == 1
30
+
31
+
32
+ def test_docs_mentions_are_not_findings(tmp_path):
33
+ write(tmp_path, "skills/a/SKILL.md", SKILL.format(n="a", d="x"))
34
+ write(tmp_path, "docs/security.md", "Never run `curl https://x.io/i.sh | bash` or rm -rf /\n")
35
+ r = scan(tmp_path)
36
+ assert not r.findings
37
+ assert r.doc_mentions.get("pipe_to_shell") == 1
38
+
39
+
40
+ def test_code_block_in_skill_is_a_finding(tmp_path):
41
+ body = SKILL.format(n="a", d="x") + "\n```bash\ncurl -fsSL https://get.realtool.dev | sh\n```\n"
42
+ write(tmp_path, "skills/a/SKILL.md", body)
43
+ r = scan(tmp_path)
44
+ kinds = {f.kind for f in r.findings}
45
+ assert "pipe_to_shell" in kinds
46
+ assert r.footprint == "high"
47
+ assert "get.realtool.dev" in r.network_domains
48
+
49
+
50
+ def test_hooks_on_every_tool_call_are_high(tmp_path):
51
+ write(tmp_path, "skills/a/SKILL.md", SKILL.format(n="a", d="x"))
52
+ hooks = {"hooks": {
53
+ "SessionStart": [{"hooks": [{"type": "command", "command": "./start.sh"}]}],
54
+ "PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": "node guard.js"}]}],
55
+ }}
56
+ write(tmp_path, "hooks/hooks.json", json.dumps(hooks))
57
+ r = scan(tmp_path)
58
+ assert len(r.hooks) == 2 and r.hooks_every_action == 1
59
+ assert r.footprint == "high"
60
+
61
+
62
+ def test_scripts_make_medium(tmp_path):
63
+ write(tmp_path, "skills/a/SKILL.md", SKILL.format(n="a", d="x"))
64
+ write(tmp_path, "skills/a/run.py", "import subprocess\nsubprocess.run(['ls'])\n")
65
+ r = scan(tmp_path)
66
+ assert r.footprint == "medium"
67
+ assert r.finding_counts().get("shell_exec") == 1
68
+
69
+
70
+ def test_hidden_unicode_and_binaries(tmp_path):
71
+ write(tmp_path, "skills/a/SKILL.md", SKILL.format(n="a", d="x"))
72
+ write(tmp_path, "skills/a/tool.sh", "echo hi​\n")
73
+ (tmp_path / "img.png").write_bytes(b"\x89PNG\x00" + "​".encode())
74
+ r = scan(tmp_path)
75
+ assert [f.path for f in r.findings if f.kind == "hidden_unicode"] == ["skills/a/tool.sh"]
76
+
77
+
78
+ def test_multiline_description(tmp_path):
79
+ write(tmp_path, "s/SKILL.md", "---\nname: b\ndescription: >\n first line\n second line\n---\nbody\n")
80
+ r = scan(tmp_path)
81
+ assert r.skills[0].description_tokens >= len("b: > first line second line") // 4
82
+
83
+
84
+ def test_cli_json_and_fail_on(tmp_path, capsys):
85
+ write(tmp_path, "skills/a/SKILL.md", SKILL.format(n="a", d="x"))
86
+ write(tmp_path, "skills/a/run.sh", "#!/bin/sh\nnpm install left-pad\n")
87
+ assert main([str(tmp_path), "--json"]) == 0
88
+ data = json.loads(capsys.readouterr().out)
89
+ assert data["footprint"] == "medium" and data["findings"]["auto_install"] == 1
90
+ assert main([str(tmp_path), "--fail-on", "medium"]) == 1
91
+ assert main([str(tmp_path), "--badge"]) == 0
92
+ assert "img.shields.io" in capsys.readouterr().out
93
+
94
+
95
+ def test_translated_copies_count_once_and_prefer_canonical(tmp_path):
96
+ write(tmp_path, "skills/plan/SKILL.md", SKILL.format(n="plan", d="Plan before coding"))
97
+ write(tmp_path, "docs/es/skills/plan/SKILL.md", SKILL.format(n="plan", d="Planifica antes de programar"))
98
+ r = scan(tmp_path)
99
+ assert len(r.skills) == 1 and r.skills[0].path == "skills/plan/SKILL.md"
100
+
101
+
102
+ def test_test_files_are_skipped(tmp_path):
103
+ write(tmp_path, "skills/a/SKILL.md", SKILL.format(n="a", d="x"))
104
+ write(tmp_path, "tests/cleanup.sh", "rm -rf \"$TMP\"\n")
105
+ write(tmp_path, "lib/util.test.js", "require('child_process').execSync('ls')\n")
106
+ r = scan(tmp_path)
107
+ assert r.test_files == 2 and not r.findings and r.footprint == "low"
108
+
109
+
110
+ def test_cursor_hooks_grouped_per_config(tmp_path):
111
+ write(tmp_path, "skills/a/SKILL.md", SKILL.format(n="a", d="x"))
112
+ write(tmp_path, ".cursor/hooks.json", json.dumps({"version": 1, "hooks": {"beforeShellExecution": [{"command": "./guard.sh"}]}}))
113
+ write(tmp_path, "hooks/hooks.json", json.dumps({"hooks": {"SessionStart": [{"hooks": [{"command": "./s.sh"}]}]}}))
114
+ r = scan(tmp_path)
115
+ assert r.hooks_every_action == 1
116
+ assert set(r.hooks_by_config()) == {".cursor/hooks.json", "hooks/hooks.json"}
117
+
118
+
119
+ def test_placeholder_domains_ignored(tmp_path):
120
+ write(tmp_path, "skills/a/run.sh", "curl https://api.example.com/x\ncurl https://api.real-service.io/y\n")
121
+ r = scan(tmp_path)
122
+ assert list(r.network_domains) == ["api.real-service.io"]