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.
- skillprint-0.1.0/LICENSE +21 -0
- skillprint-0.1.0/PKG-INFO +110 -0
- skillprint-0.1.0/README.md +92 -0
- skillprint-0.1.0/pyproject.toml +30 -0
- skillprint-0.1.0/setup.cfg +4 -0
- skillprint-0.1.0/skillprint/__init__.py +2 -0
- skillprint-0.1.0/skillprint/cli.py +128 -0
- skillprint-0.1.0/skillprint/scan.py +289 -0
- skillprint-0.1.0/skillprint.egg-info/PKG-INFO +110 -0
- skillprint-0.1.0/skillprint.egg-info/SOURCES.txt +12 -0
- skillprint-0.1.0/skillprint.egg-info/dependency_links.txt +1 -0
- skillprint-0.1.0/skillprint.egg-info/entry_points.txt +2 -0
- skillprint-0.1.0/skillprint.egg-info/top_level.txt +1 -0
- skillprint-0.1.0/tests/test_scan.py +122 -0
skillprint-0.1.0/LICENSE
ADDED
|
@@ -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
|
+
[](https://github.com/mekala540/skillprint/actions/workflows/tests.yml)  
|
|
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
|
+
[](https://github.com/mekala540/skillprint/actions/workflows/tests.yml)  
|
|
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,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"[](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
|
+
[](https://github.com/mekala540/skillprint/actions/workflows/tests.yml)  
|
|
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 @@
|
|
|
1
|
+
|
|
@@ -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"]
|