skillscout-cli 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.
- skillscout/__init__.py +1 -0
- skillscout/cli.py +97 -0
- skillscout/core.py +293 -0
- skillscout/github.py +168 -0
- skillscout/profile.example.toml +52 -0
- skillscout/scan.py +110 -0
- skillscout/schedule.py +75 -0
- skillscout/score.py +61 -0
- skillscout/skill.py +54 -0
- skillscout_cli-0.1.0.dist-info/METADATA +183 -0
- skillscout_cli-0.1.0.dist-info/RECORD +15 -0
- skillscout_cli-0.1.0.dist-info/WHEEL +5 -0
- skillscout_cli-0.1.0.dist-info/entry_points.txt +2 -0
- skillscout_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- skillscout_cli-0.1.0.dist-info/top_level.txt +1 -0
skillscout/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Keep an agent's skills current: find what's new, diff what changed, scan everything, install only what you approve."""
|
skillscout/cli.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""skillscout command line."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import __doc__ as ABOUT, core, schedule
|
|
11
|
+
|
|
12
|
+
EXAMPLE = Path(__file__).resolve().parent / "profile.example.toml"
|
|
13
|
+
USER_PROFILE = Path.home() / ".skillscout" / "profile.toml"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def notify(text: str) -> None:
|
|
17
|
+
if sys.platform == "darwin" and shutil.which("osascript"):
|
|
18
|
+
subprocess.run(["osascript", "-e", f'display notification "{text}" with title "skillscout"'], check=False)
|
|
19
|
+
elif shutil.which("notify-send"):
|
|
20
|
+
subprocess.run(["notify-send", "skillscout", text], check=False)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main(argv: list[str] | None = None) -> None:
|
|
24
|
+
p = argparse.ArgumentParser(prog="skillscout", description=ABOUT)
|
|
25
|
+
p.add_argument("--profile", type=Path, default=None, help="default: ~/.skillscout/profile.toml, else the example")
|
|
26
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
27
|
+
sub.add_parser("init", help="copy the example profile to ~/.skillscout/profile.toml")
|
|
28
|
+
d = sub.add_parser("discover", help="search GitHub, scan and score skills, write a report")
|
|
29
|
+
d.add_argument("--full", action="store_true", help="ignore caches: fetch every repo and re-scan every skill")
|
|
30
|
+
d.add_argument("--notify", action="store_true", help="desktop notification with the summary (macOS, or Linux with notify-send)")
|
|
31
|
+
ls = sub.add_parser("list", help="show scored candidates")
|
|
32
|
+
ls.add_argument("--all", action="store_true", help="include zero-relevance skills")
|
|
33
|
+
ls.add_argument("-n", type=int, default=30)
|
|
34
|
+
sh = sub.add_parser("show", help="print a candidate's flags and full SKILL.md")
|
|
35
|
+
sh.add_argument("id")
|
|
36
|
+
ap = sub.add_parser("approve", help="install one skill into ~/.claude/skills and pin it")
|
|
37
|
+
ap.add_argument("id")
|
|
38
|
+
ap.add_argument("--accept-risk", action="store_true", help="install despite high-risk flags")
|
|
39
|
+
ap.add_argument("--update", action="store_true", help="replace a copy skillscout installed earlier")
|
|
40
|
+
rm = sub.add_parser("remove", help="uninstall a skill skillscout installed")
|
|
41
|
+
rm.add_argument("id")
|
|
42
|
+
sub.add_parser("installed", help="show pinned skills")
|
|
43
|
+
sub.add_parser("updates", help="diff pinned skills against upstream; applies nothing")
|
|
44
|
+
sub.add_parser("schedule", help="set up weekly discovery for this OS (you turn it on)")
|
|
45
|
+
a = p.parse_args(argv)
|
|
46
|
+
|
|
47
|
+
paths = core.Paths()
|
|
48
|
+
if a.cmd == "init":
|
|
49
|
+
if USER_PROFILE.exists():
|
|
50
|
+
raise SystemExit(f"{USER_PROFILE} already exists")
|
|
51
|
+
USER_PROFILE.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
USER_PROFILE.write_text(EXAMPLE.read_text())
|
|
53
|
+
print(f"wrote {USER_PROFILE}; edit [keywords] to match your work, then run `skillscout discover`")
|
|
54
|
+
return
|
|
55
|
+
profile = core.load_profile(a.profile or (USER_PROFILE if USER_PROFILE.exists() else EXAMPLE))
|
|
56
|
+
cands = core._read(paths.candidates, [])
|
|
57
|
+
|
|
58
|
+
if a.cmd == "discover":
|
|
59
|
+
cands = core.discover(profile, paths, full=a.full)
|
|
60
|
+
out, summary = core.report(cands, profile, paths)
|
|
61
|
+
print(f"{summary}\nreport: {out}")
|
|
62
|
+
if a.notify:
|
|
63
|
+
notify(summary)
|
|
64
|
+
elif a.cmd == "list":
|
|
65
|
+
rows = [c for c in cands if a.all or c["relevance"] > 0][: a.n]
|
|
66
|
+
for c in rows:
|
|
67
|
+
tag = "installed" if c["installed"] else ("REC" if core.recommended(c, profile) else "")
|
|
68
|
+
print(f"{c['score']:.2f} {c['risk'] or '-':6} {tag:9} {c['id']}")
|
|
69
|
+
elif a.cmd == "show":
|
|
70
|
+
c = core.find(cands, a.id)
|
|
71
|
+
print(json.dumps({k: c[k] for k in ("id", "score", "stars", "license", "commit", "risk", "matched", "conflicts")}, indent=2))
|
|
72
|
+
for f in c["flags"]:
|
|
73
|
+
print(f" [{f['level']}] {f['reason']} — {f['where']}")
|
|
74
|
+
src = core.source_dir(c, paths)
|
|
75
|
+
print(f"\nfiles: {', '.join(sorted(str(x.relative_to(src)) for x in src.rglob('*') if x.is_file()))}\n")
|
|
76
|
+
print((src / "SKILL.md").read_text(errors="ignore"))
|
|
77
|
+
elif a.cmd == "approve":
|
|
78
|
+
c = core.find(cands, a.id)
|
|
79
|
+
print(f"installed {c['id']} -> {core.approve(c, paths, a.accept_risk, a.update)}")
|
|
80
|
+
elif a.cmd == "remove":
|
|
81
|
+
print(f"removed {core.remove(a.id, paths)}")
|
|
82
|
+
elif a.cmd == "installed":
|
|
83
|
+
for cid, e in core._read(paths.lock, {}).items():
|
|
84
|
+
print(f"{cid} @{e['commit'][:10]} {e['installed_at']}")
|
|
85
|
+
elif a.cmd == "updates":
|
|
86
|
+
found = core.updates(paths)
|
|
87
|
+
for cid, commit, diff in found:
|
|
88
|
+
print(f"\n=== {cid} -> {commit[:10]}\n{diff}")
|
|
89
|
+
print(f"\n{len(found)} pinned skill(s) changed upstream." + (" Re-run `approve <id> --update` after reading the diff." if found else ""))
|
|
90
|
+
elif a.cmd == "schedule":
|
|
91
|
+
files, message = schedule.here(paths.state)
|
|
92
|
+
schedule.write(files)
|
|
93
|
+
print(message)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
if __name__ == "__main__":
|
|
97
|
+
main()
|
skillscout/core.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Discovery, reporting, approval and update checks."""
|
|
2
|
+
|
|
3
|
+
import difflib
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
import tomllib
|
|
8
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
9
|
+
from dataclasses import asdict
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
|
|
15
|
+
from . import github
|
|
16
|
+
from .scan import HIGH, RULES, scan, worst
|
|
17
|
+
from .score import combined, conflicts, freshness, popularity, relevance
|
|
18
|
+
from .skill import find_skills, parse_frontmatter
|
|
19
|
+
|
|
20
|
+
HOME = Path.home()
|
|
21
|
+
STATE = HOME / ".skillscout"
|
|
22
|
+
SKILLS_DIR = HOME / ".claude" / "skills"
|
|
23
|
+
PLUGIN_CACHE = HOME / ".claude" / "plugins" / "cache"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Paths:
|
|
27
|
+
def __init__(self, state: Path = STATE, skills: Path = SKILLS_DIR, plugins: Path = PLUGIN_CACHE):
|
|
28
|
+
self.state, self.skills, self.plugins = state, skills, plugins
|
|
29
|
+
self.cache = state / "repos"
|
|
30
|
+
self.candidates = state / "candidates.json"
|
|
31
|
+
self.repos = state / "repos.json" # each repo's metadata, push time, checkout and commit at the last run
|
|
32
|
+
self.lock = state / "skills.lock.json"
|
|
33
|
+
self.reports = state / "reports"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_profile(path: Path) -> dict:
|
|
37
|
+
return tomllib.loads(path.read_text())
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _read(path: Path, default):
|
|
41
|
+
return json.loads(path.read_text()) if path.exists() else default
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _write(path: Path, data) -> None:
|
|
45
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def digest(folder: Path) -> str:
|
|
50
|
+
h = hashlib.sha256()
|
|
51
|
+
for f in sorted(p for p in folder.rglob("*") if p.is_file() and not p.is_symlink()):
|
|
52
|
+
h.update(str(f.relative_to(folder)).encode() + b"\0" + f.read_bytes())
|
|
53
|
+
return h.hexdigest()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def installed_skills(paths: Paths) -> dict[str, str]:
|
|
57
|
+
"""name -> description for user skills and plugin skills (plugin ones as `plugin:name`)."""
|
|
58
|
+
out = {}
|
|
59
|
+
for root, prefix in ((paths.skills, ""), (paths.plugins, "plugin")):
|
|
60
|
+
if not root.exists():
|
|
61
|
+
continue
|
|
62
|
+
for md in root.rglob("SKILL.md"):
|
|
63
|
+
if ".git" in md.parts or "node_modules" in md.parts:
|
|
64
|
+
continue
|
|
65
|
+
meta = parse_frontmatter(md.read_text(errors="ignore"))
|
|
66
|
+
name = meta.get("name") or md.parent.name
|
|
67
|
+
out[f"{prefix}:{name}" if prefix else name] = meta.get("description", "")
|
|
68
|
+
return out
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def follow_lists(repos: dict, s: dict, fetch=github) -> dict:
|
|
72
|
+
"""Repos linked from curated lists: the profile's `lists` plus any found repo named awesome-*.
|
|
73
|
+
Each link costs one API lookup, so at most `max_list_repos` are looked up per run; the same filters
|
|
74
|
+
(stars, freshness, size) then apply as to search results."""
|
|
75
|
+
if not s.get("follow_lists", True) or not hasattr(fetch, "readme"):
|
|
76
|
+
return {}
|
|
77
|
+
lists = set(s.get("lists", [])) | {n for n in repos if "awesome" in n.split("/")[-1].lower()}
|
|
78
|
+
known = {n.lower() for n in repos}
|
|
79
|
+
names, via = [], {}
|
|
80
|
+
for lst in sorted(lists):
|
|
81
|
+
try:
|
|
82
|
+
text = fetch.readme(lst)
|
|
83
|
+
except Exception as e:
|
|
84
|
+
print(f"list {lst}: {e}")
|
|
85
|
+
continue
|
|
86
|
+
for n in github.linked_repos(text):
|
|
87
|
+
if n.lower() not in known and n.lower() != lst.lower() and n not in via:
|
|
88
|
+
names.append(n)
|
|
89
|
+
via[n] = lst
|
|
90
|
+
cap = s.get("max_list_repos", 300 if github.token() else 40)
|
|
91
|
+
|
|
92
|
+
def look(n):
|
|
93
|
+
try:
|
|
94
|
+
return {**fetch.repo(n), "via": via[n]}
|
|
95
|
+
except Exception: # renamed, deleted or private: skip quietly
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
99
|
+
found = [r for r in pool.map(look, names[:cap]) if r]
|
|
100
|
+
if lists:
|
|
101
|
+
print(f"followed {len(lists)} lists: {len(names)} linked repos, looked up {min(len(names), cap)}"
|
|
102
|
+
+ (f"; {len(names) - cap} more need a token or a higher max_list_repos" if len(names) > cap else ""))
|
|
103
|
+
return {r["full_name"]: r for r in found if r["full_name"].lower() not in known}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def discover(profile: dict, paths: Paths, fetch=github, full: bool = False) -> list[dict]:
|
|
107
|
+
"""Search, clone, scan and score. Writes candidates.json and returns the candidates, best first.
|
|
108
|
+
Repos with no new pushes aren't fetched and unchanged skills aren't re-scanned, unless `full`."""
|
|
109
|
+
s = profile["search"]
|
|
110
|
+
seen = {} if full else _read(paths.repos, {})
|
|
111
|
+
repos, failed = {}, False
|
|
112
|
+
for q in s["queries"]:
|
|
113
|
+
try:
|
|
114
|
+
for r in fetch.search(q, s.get("per_query", 30)):
|
|
115
|
+
repos.setdefault(r["full_name"], r)
|
|
116
|
+
except Exception as e: # a failed search falls back to last run's repos below
|
|
117
|
+
print(f"search {q!r} failed: {e}")
|
|
118
|
+
failed = True
|
|
119
|
+
for name in s.get("trusted", []):
|
|
120
|
+
try:
|
|
121
|
+
repos[name] = repos.get(name) or fetch.repo(name)
|
|
122
|
+
except Exception as e:
|
|
123
|
+
print(f"lookup {name} failed: {e}")
|
|
124
|
+
failed = True
|
|
125
|
+
repos.update(follow_lists(repos, s, fetch))
|
|
126
|
+
if failed:
|
|
127
|
+
for name, e in seen.items():
|
|
128
|
+
repos.setdefault(name, e["meta"])
|
|
129
|
+
|
|
130
|
+
now = datetime.now(timezone.utc)
|
|
131
|
+
trusted = set(s.get("trusted", []))
|
|
132
|
+
keep = [r for r in repos.values() if r["full_name"] in trusted or (
|
|
133
|
+
not r["archived"] and r["stars"] >= s.get("min_stars", 0)
|
|
134
|
+
and r.get("size_kb", 0) <= s.get("max_repo_mb", 300) * 1024
|
|
135
|
+
and freshness(r["pushed_at"], now) > 1 - s.get("max_age_days", 365) / 365)]
|
|
136
|
+
|
|
137
|
+
installed = installed_skills(paths)
|
|
138
|
+
locked = _read(paths.lock, {})
|
|
139
|
+
previous = {c["id"]: c for c in _read(paths.candidates, [])}
|
|
140
|
+
stale = set() # repos whose refresh failed: don't record their push time, so the next run retries
|
|
141
|
+
if not github.token():
|
|
142
|
+
print("tip: set GITHUB_TOKEN or run `gh auth login` for higher GitHub API limits (5,000 requests an hour instead of 60)")
|
|
143
|
+
|
|
144
|
+
def pull(r):
|
|
145
|
+
last = seen.get(r["full_name"])
|
|
146
|
+
if last and last["meta"]["pushed_at"] == r["pushed_at"] and (Path(last["root"]) / ".git").exists():
|
|
147
|
+
return r, Path(last["root"]), last["commit"] # nothing pushed since the last run: no fetch
|
|
148
|
+
try:
|
|
149
|
+
return r, *fetch.sync(r["full_name"], paths.cache)
|
|
150
|
+
except github.Stale as e:
|
|
151
|
+
print(f"{r['full_name']}: {e}")
|
|
152
|
+
stale.add(r["full_name"])
|
|
153
|
+
return r, e.root, e.commit
|
|
154
|
+
except Exception as e: # one bad repo shouldn't stop the run
|
|
155
|
+
print(f"skip {r['full_name']}: {e}")
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
with ThreadPoolExecutor(max_workers=s.get("workers", 16)) as pool:
|
|
159
|
+
synced = [x for x in pool.map(pull, keep) if x]
|
|
160
|
+
_write(paths.repos, {**seen, **{r["full_name"]: {"meta": r, "root": str(root), "commit": commit}
|
|
161
|
+
for r, root, commit in synced if r["full_name"] not in stale}})
|
|
162
|
+
print(f"fetched {sum(1 for r, *_ in synced if seen.get(r['full_name'], {}).get('meta', {}).get('pushed_at') != r['pushed_at'])} "
|
|
163
|
+
f"of {len(synced)} repos; the rest had no new pushes")
|
|
164
|
+
|
|
165
|
+
candidates, reused = [], 0
|
|
166
|
+
for r, root, commit in synced:
|
|
167
|
+
try:
|
|
168
|
+
tree_of = github.trees(root)
|
|
169
|
+
except Exception:
|
|
170
|
+
tree_of = {}
|
|
171
|
+
licensed = any((root / n).exists() for n in ("LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING"))
|
|
172
|
+
for sk in find_skills(root):
|
|
173
|
+
rel, hits = relevance(f"{sk.name} {sk.description}", profile["keywords"])
|
|
174
|
+
cid = f"{r['full_name']}:{sk.name}"
|
|
175
|
+
tree = tree_of.get(str(sk.path.relative_to(root)))
|
|
176
|
+
key = f"{RULES}:{tree}:{int(licensed)}" if tree else None
|
|
177
|
+
old = previous.get(cid)
|
|
178
|
+
if key and old and old.get("scan_key") == key and not full: # same files, same rules: the old result stands
|
|
179
|
+
flags, reused = old["flags"], reused + 1
|
|
180
|
+
else:
|
|
181
|
+
flags = [asdict(f) for f in scan(sk.path, root)]
|
|
182
|
+
candidates.append({
|
|
183
|
+
"id": cid, "repo": r["full_name"], "name": sk.name, "description": sk.description,
|
|
184
|
+
"path": str(sk.path.relative_to(root)), "commit": commit, "stars": r["stars"],
|
|
185
|
+
"license": r["license"], "pushed_at": r["pushed_at"], "trusted": r["full_name"] in trusted,
|
|
186
|
+
"relevance": round(rel, 3), "matched": hits,
|
|
187
|
+
"score": combined(rel, popularity(r["stars"]), freshness(r["pushed_at"], now), r["full_name"] in trusted),
|
|
188
|
+
"flags": flags, "risk": worst(flags), "scan_key": key,
|
|
189
|
+
"conflicts": conflicts(sk.name, sk.description, installed, profile["scoring"]["overlap_at"]),
|
|
190
|
+
"installed": cid in locked, "new": cid not in previous,
|
|
191
|
+
})
|
|
192
|
+
print(f"scanned {len(candidates) - reused} skills, reused {reused} unchanged")
|
|
193
|
+
candidates.sort(key=lambda c: c["score"], reverse=True)
|
|
194
|
+
_write(paths.candidates, candidates)
|
|
195
|
+
return candidates
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def recommended(c: dict, profile: dict) -> bool:
|
|
199
|
+
sc = profile["scoring"]
|
|
200
|
+
return (c["score"] >= sc["recommend_at"] and c["relevance"] >= sc.get("min_relevance", 0.3) and c["risk"] != HIGH
|
|
201
|
+
and not c["conflicts"] and not c["installed"])
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def report(candidates: list[dict], profile: dict, paths: Paths, top: int = 25) -> tuple[Path, str]:
|
|
205
|
+
rec = [c for c in candidates if recommended(c, profile)]
|
|
206
|
+
flagged = [c for c in candidates if c["risk"] == HIGH and c["relevance"] > 0]
|
|
207
|
+
new = [c for c in candidates if c["new"]]
|
|
208
|
+
summary = f"{len(new)} new, {len(rec)} recommended, {len(flagged)} flagged high-risk"
|
|
209
|
+
lines = [f"# Skill report {datetime.now():%Y-%m-%d}", "", summary, "", "## New since last run", ""]
|
|
210
|
+
fresh = [c for c in rec if c["new"]]
|
|
211
|
+
if new and len(new) == len(candidates):
|
|
212
|
+
lines.append("First run: everything is new. From the next run, this section lists only what appeared since.")
|
|
213
|
+
elif fresh:
|
|
214
|
+
lines += [f"- **`{c['id']}`** ({c['stars']}★, score {c['score']:.2f}): {c['description'][:140]}" for c in fresh[:top]]
|
|
215
|
+
else:
|
|
216
|
+
lines.append("Nothing new that fits your profile.")
|
|
217
|
+
lines += ["", "Run `skillscout updates` to see what changed in the skills you already have.", "",
|
|
218
|
+
"## Recommended", "", "| Score | Skill | Stars | Risk | Matched | Why |", "|---|---|---|---|---|---|"]
|
|
219
|
+
for c in rec[:top]:
|
|
220
|
+
lines.append(f"| {c['score']:.2f} | `{c['id']}`{' 🆕' if c['new'] else ''} | {c['stars']} | {c['risk'] or 'none'} "
|
|
221
|
+
f"| {', '.join(c['matched'][:4])} | {c['description'][:110]} |")
|
|
222
|
+
lines += ["", "Install one with `skillscout approve <id>` after reading it with `skillscout show <id>`.", "",
|
|
223
|
+
"## Relevant but blocked", ""]
|
|
224
|
+
for c in [c for c in candidates if c["relevance"] > 0 and not recommended(c, profile) and not c["installed"]][:top]:
|
|
225
|
+
why = c["conflicts"] + [f"{f['level']}: {f['reason']} ({f['where']})" for f in c["flags"] if f["level"] == HIGH]
|
|
226
|
+
if why:
|
|
227
|
+
lines.append(f"- `{c['id']}`: {'; '.join(why[:3])}")
|
|
228
|
+
paths.reports.mkdir(parents=True, exist_ok=True)
|
|
229
|
+
out = paths.reports / f"{datetime.now():%Y-%m-%d}.md"
|
|
230
|
+
out.write_text("\n".join(lines) + "\n")
|
|
231
|
+
return out, summary
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def find(candidates: list[dict], cid: str) -> dict:
|
|
235
|
+
match = [c for c in candidates if c["id"] == cid] or [c for c in candidates if c["id"].endswith(":" + cid)]
|
|
236
|
+
if len(match) != 1:
|
|
237
|
+
raise SystemExit(f"{'no' if not match else 'ambiguous'} candidate matches {cid!r}; run `skillscout list`")
|
|
238
|
+
return match[0]
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def source_dir(c: dict, paths: Paths) -> Path:
|
|
242
|
+
return paths.cache / c["repo"].replace("/", "__") / c["path"]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def approve(c: dict, paths: Paths, accept_risk: bool = False, replace: bool = False) -> Path:
|
|
246
|
+
"""Copy one skill into ~/.claude/skills and pin it in the lockfile."""
|
|
247
|
+
if c["risk"] == HIGH and not accept_risk:
|
|
248
|
+
raise SystemExit(f"{c['id']} has high-risk flags; read it with `show`, then pass --accept-risk")
|
|
249
|
+
src, dest = source_dir(c, paths), paths.skills / c["name"]
|
|
250
|
+
locked = _read(paths.lock, {})
|
|
251
|
+
if dest.exists():
|
|
252
|
+
ours = locked.get(c["id"], {}).get("digest") == digest(dest)
|
|
253
|
+
if not (replace and ours):
|
|
254
|
+
raise SystemExit(f"{dest} already exists" + ("" if ours else " and wasn't installed by skillscout"))
|
|
255
|
+
shutil.rmtree(dest)
|
|
256
|
+
# Symlinks are dropped: following one could copy files from outside the skill (e.g. ~/.ssh)
|
|
257
|
+
shutil.copytree(src, dest, ignore=lambda d, names: [n for n in names if n == ".git" or Path(d, n).is_symlink()])
|
|
258
|
+
# Pin what is actually in the cache now (an `update` check may have moved it past discovery time)
|
|
259
|
+
commit = github.git("rev-parse", "HEAD", cwd=paths.cache / c["repo"].replace("/", "__"))
|
|
260
|
+
locked[c["id"]] = {"repo": c["repo"], "path": c["path"], "commit": commit, "digest": digest(dest),
|
|
261
|
+
"dest": str(dest), "installed_at": datetime.now(timezone.utc).isoformat(timespec="seconds")}
|
|
262
|
+
_write(paths.lock, locked)
|
|
263
|
+
return dest
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def remove(cid: str, paths: Paths) -> Path:
|
|
267
|
+
locked = _read(paths.lock, {})
|
|
268
|
+
if cid not in locked:
|
|
269
|
+
raise SystemExit(f"{cid} isn't in the lockfile")
|
|
270
|
+
dest = Path(locked.pop(cid)["dest"])
|
|
271
|
+
if dest.exists():
|
|
272
|
+
shutil.rmtree(dest)
|
|
273
|
+
_write(paths.lock, locked)
|
|
274
|
+
return dest
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def updates(paths: Paths, fetch=github) -> list[tuple[str, str, str]]:
|
|
278
|
+
"""(id, new commit, unified diff) for each pinned skill whose source changed. Applies nothing."""
|
|
279
|
+
out = []
|
|
280
|
+
for cid, entry in _read(paths.lock, {}).items():
|
|
281
|
+
root, commit = fetch.sync(entry["repo"], paths.cache)
|
|
282
|
+
if commit == entry["commit"]:
|
|
283
|
+
continue
|
|
284
|
+
new, old = root / entry["path"], Path(entry["dest"])
|
|
285
|
+
diff = []
|
|
286
|
+
names = sorted({str(p.relative_to(d)) for d in (old, new) if d.exists() for p in d.rglob("*") if p.is_file()})
|
|
287
|
+
for n in names:
|
|
288
|
+
a = (old / n).read_text(errors="ignore").splitlines() if (old / n).exists() else []
|
|
289
|
+
b = (new / n).read_text(errors="ignore").splitlines() if (new / n).exists() else []
|
|
290
|
+
diff += difflib.unified_diff(a, b, f"installed/{n}", f"upstream/{n}", lineterm="")
|
|
291
|
+
if diff:
|
|
292
|
+
out.append((cid, commit, "\n".join(diff)))
|
|
293
|
+
return out
|
skillscout/github.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""GitHub search (REST API) and shallow clones. A token is optional: GITHUB_TOKEN, or the GitHub CLI's login if `gh` is installed."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import time
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.parse
|
|
11
|
+
import urllib.request
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
API = "https://api.github.com"
|
|
15
|
+
MAX_WAIT = 120 # seconds to wait out one rate-limit window
|
|
16
|
+
TRIES = 4
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_token = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def token() -> str:
|
|
23
|
+
"""GITHUB_TOKEN, else the GitHub CLI's login, else empty (anonymous: 60 API requests an hour)."""
|
|
24
|
+
global _token
|
|
25
|
+
if _token is None:
|
|
26
|
+
_token = os.environ.get("GITHUB_TOKEN", "")
|
|
27
|
+
if not _token and shutil.which("gh"):
|
|
28
|
+
try:
|
|
29
|
+
_token = subprocess.run(["gh", "auth", "token"], capture_output=True, text=True, timeout=10).stdout.strip()
|
|
30
|
+
except (subprocess.SubprocessError, OSError):
|
|
31
|
+
_token = ""
|
|
32
|
+
return _token
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _get(path: str) -> dict:
|
|
36
|
+
req = urllib.request.Request(API + path, headers={"Accept": "application/vnd.github+json",
|
|
37
|
+
"User-Agent": "skillscout"})
|
|
38
|
+
if t := token():
|
|
39
|
+
req.add_header("Authorization", f"Bearer {t}")
|
|
40
|
+
for attempt in range(TRIES):
|
|
41
|
+
try:
|
|
42
|
+
with urllib.request.urlopen(req, timeout=30) as r:
|
|
43
|
+
return json.load(r)
|
|
44
|
+
except urllib.error.HTTPError as e:
|
|
45
|
+
limited = e.code == 429 or (e.code == 403 and e.headers.get("X-RateLimit-Remaining") == "0")
|
|
46
|
+
if not limited or attempt == TRIES - 1:
|
|
47
|
+
raise
|
|
48
|
+
reset = e.headers.get("Retry-After") or max(0, int(e.headers.get("X-RateLimit-Reset", 0)) - int(time.time()))
|
|
49
|
+
wait = min(int(reset) + 1, MAX_WAIT)
|
|
50
|
+
print(f"GitHub rate limit hit; waiting {wait}s" + ("" if token() else " (set GITHUB_TOKEN or log in with `gh auth login` to avoid this)"))
|
|
51
|
+
time.sleep(wait)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _repo(d: dict) -> dict:
|
|
55
|
+
return {"full_name": d["full_name"], "stars": d.get("stargazers_count", 0), "pushed_at": d.get("pushed_at"),
|
|
56
|
+
"license": (d.get("license") or {}).get("spdx_id"), "archived": d.get("archived", False),
|
|
57
|
+
"description": d.get("description") or "", "size_kb": d.get("size", 0)}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def search(query: str, per_page: int) -> list[dict]:
|
|
61
|
+
q = urllib.parse.quote(query)
|
|
62
|
+
return [_repo(d) for d in _get(f"/search/repositories?q={q}&sort=stars&order=desc&per_page={per_page}")["items"]]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def repo(full_name: str) -> dict:
|
|
66
|
+
return _repo(_get(f"/repos/{full_name}"))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def readme(full_name: str) -> str:
|
|
70
|
+
"""The README at the default branch, from raw.githubusercontent.com (no API quota used)."""
|
|
71
|
+
for name in ("README.md", "readme.md", "README.markdown", "README"):
|
|
72
|
+
req = urllib.request.Request(f"https://raw.githubusercontent.com/{full_name}/HEAD/{name}",
|
|
73
|
+
headers={"User-Agent": "skillscout"})
|
|
74
|
+
try:
|
|
75
|
+
with urllib.request.urlopen(req, timeout=30) as r:
|
|
76
|
+
return r.read().decode("utf-8", "replace")
|
|
77
|
+
except urllib.error.HTTPError as e:
|
|
78
|
+
if e.code != 404:
|
|
79
|
+
raise
|
|
80
|
+
raise FileNotFoundError(f"no README in {full_name}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# github.com paths that look like owner/repo but aren't repos
|
|
84
|
+
NOT_OWNERS = {"about", "apps", "collections", "contact", "customer-stories", "enterprise", "features", "login",
|
|
85
|
+
"marketplace", "orgs", "pricing", "readme", "security", "settings", "site", "sponsors", "topics",
|
|
86
|
+
"trending", "users", "join", "explore", "notifications", "new", "codespaces", "events"}
|
|
87
|
+
_LINK = re.compile(r"https?://(?:www\.)?github\.com/([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))/([A-Za-z0-9._-]{1,100})")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def linked_repos(text: str) -> list[str]:
|
|
91
|
+
"""owner/repo for every GitHub repo linked from markdown, first mention first, no duplicates."""
|
|
92
|
+
out, seen = [], set()
|
|
93
|
+
for owner, name in _LINK.findall(text):
|
|
94
|
+
name = name.removesuffix(".git").rstrip(".")
|
|
95
|
+
if owner.lower() in NOT_OWNERS or not name or name.startswith("."):
|
|
96
|
+
continue
|
|
97
|
+
full = f"{owner}/{name}"
|
|
98
|
+
if full.lower() not in seen:
|
|
99
|
+
seen.add(full.lower())
|
|
100
|
+
out.append(full)
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def git(*args: str, cwd: Path | None = None, stdin: str | None = None) -> str:
|
|
105
|
+
return subprocess.run(["git", *args], cwd=cwd, input=stdin, check=True, capture_output=True, text=True,
|
|
106
|
+
timeout=120, env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}).stdout.strip()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
SKIP_DIRS = {"node_modules", "dist", "build", ".git"}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# Media, fonts and archives aren't needed to judge a skill and are most of the bytes in big repos.
|
|
113
|
+
# (Sizes would need every blob fetched one by one in a blobless clone, so filter by extension instead.)
|
|
114
|
+
HEAVY = ["png", "jpg", "jpeg", "gif", "webp", "avif", "svg", "ico", "mp4", "mov", "webm", "mkv", "mp3", "wav",
|
|
115
|
+
"pdf", "zip", "tar", "gz", "7z", "psd", "fig", "ttf", "otf", "woff", "woff2", "bin", "onnx", "pt",
|
|
116
|
+
"safetensors", "parquet", "npy", "pkl"]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _skill_patterns(dest: Path, ref: str) -> list[str]:
|
|
120
|
+
"""Sparse-checkout patterns: folders that hold a SKILL.md (minus heavy files and vendored dirs), plus licences."""
|
|
121
|
+
files = git("ls-tree", "-r", "--name-only", ref, cwd=dest).splitlines()
|
|
122
|
+
dirs = {str(Path(f).parent) for f in files
|
|
123
|
+
if Path(f).name == "SKILL.md" and not SKIP_DIRS & set(Path(f).parts)}
|
|
124
|
+
escape = lambda p: "".join("\\" + c if c in "*?[]!#\\" else c for c in p)
|
|
125
|
+
keep = sorted("/*" if d == "." else f"/{escape(d)}/" for d in dirs)
|
|
126
|
+
drop = [f"!*.{ext}" for ext in HEAVY] + [f"!**/{d}/" for d in sorted(SKIP_DIRS)]
|
|
127
|
+
return ["/LICENSE*", "/COPYING*"] + keep + drop
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def trees(root: Path) -> dict[str, str]:
|
|
131
|
+
"""Folder path -> git tree hash at HEAD, one git call per repo. A folder's hash changes when any file in it does."""
|
|
132
|
+
out = {".": git("rev-parse", "HEAD^{tree}", cwd=root)}
|
|
133
|
+
for line in git("ls-tree", "-r", "-d", "HEAD", cwd=root).splitlines():
|
|
134
|
+
meta, path = line.split("\t", 1)
|
|
135
|
+
out[path] = meta.split()[2]
|
|
136
|
+
return out
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class Stale(Exception):
|
|
140
|
+
"""The refresh failed; the cached checkout is usable but may be behind upstream."""
|
|
141
|
+
|
|
142
|
+
def __init__(self, root: Path, commit: str):
|
|
143
|
+
super().__init__(f"refresh failed, using the cached copy at {commit[:10]}")
|
|
144
|
+
self.root, self.commit = root, commit
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def sync(full_name: str, cache: Path) -> tuple[Path, str]:
|
|
148
|
+
"""Sparse, shallow clone (only skill folders are downloaded) or refresh. Returns (path, commit).
|
|
149
|
+
Never runs repo code."""
|
|
150
|
+
dest = cache / full_name.replace("/", "__")
|
|
151
|
+
if (dest / ".git").exists():
|
|
152
|
+
for attempt in range(2):
|
|
153
|
+
try:
|
|
154
|
+
git("fetch", "-q", "--depth", "1", "--filter=blob:none", "origin", "HEAD", cwd=dest)
|
|
155
|
+
break
|
|
156
|
+
except subprocess.SubprocessError:
|
|
157
|
+
if attempt: # refresh failed twice: keep the cached copy rather than dropping the repo
|
|
158
|
+
raise Stale(dest, git("rev-parse", "HEAD", cwd=dest))
|
|
159
|
+
time.sleep(3)
|
|
160
|
+
ref = "FETCH_HEAD"
|
|
161
|
+
else:
|
|
162
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
163
|
+
git("clone", "-q", "--depth", "1", "--no-tags", "--filter=blob:none", "--no-checkout",
|
|
164
|
+
f"https://github.com/{full_name}.git", str(dest))
|
|
165
|
+
ref = "HEAD"
|
|
166
|
+
git("sparse-checkout", "set", "--no-cone", "--stdin", cwd=dest, stdin="\n".join(_skill_patterns(dest, ref)) + "\n")
|
|
167
|
+
git("reset", "-q", "--hard", ref, cwd=dest)
|
|
168
|
+
return dest, git("rev-parse", "HEAD", cwd=dest)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# skillscout profile. `skillscout init` copies this to ~/.skillscout/profile.toml; edit that copy.
|
|
2
|
+
# Keyword weights say how much a match matters. Replace them with words from your own work.
|
|
3
|
+
|
|
4
|
+
[search]
|
|
5
|
+
# GitHub repository-search queries, run on every `skillscout discover`.
|
|
6
|
+
queries = [
|
|
7
|
+
"topic:claude-skills",
|
|
8
|
+
"topic:agent-skills",
|
|
9
|
+
"topic:claude-code-skills",
|
|
10
|
+
"topic:claude-code-plugin",
|
|
11
|
+
"\"SKILL.md\" claude in:readme",
|
|
12
|
+
]
|
|
13
|
+
per_query = 30 # repos per query, by stars
|
|
14
|
+
min_stars = 50
|
|
15
|
+
max_age_days = 365 # skip repos with no push in this window
|
|
16
|
+
max_repo_mb = 300 # skip huge repos (trusted ones are always fetched)
|
|
17
|
+
workers = 16 # repos fetched at once; lower it on a slow connection
|
|
18
|
+
|
|
19
|
+
# Curated lists whose README links are followed (any found repo named awesome-* is followed too).
|
|
20
|
+
# Each linked repo costs one API lookup: 300 a run with a token, 40 without.
|
|
21
|
+
follow_lists = true
|
|
22
|
+
lists = [
|
|
23
|
+
"hesreallyhim/awesome-claude-code",
|
|
24
|
+
"travisvn/awesome-claude-skills",
|
|
25
|
+
"ComposioHQ/awesome-claude-skills",
|
|
26
|
+
"VoltAgent/awesome-agent-skills",
|
|
27
|
+
]
|
|
28
|
+
# max_list_repos = 300
|
|
29
|
+
|
|
30
|
+
# Always checked, whatever the search returns.
|
|
31
|
+
trusted = [
|
|
32
|
+
"anthropics/skills",
|
|
33
|
+
"obra/superpowers",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[keywords]
|
|
37
|
+
"python" = 2
|
|
38
|
+
"typescript" = 2
|
|
39
|
+
"react" = 2
|
|
40
|
+
"testing" = 2
|
|
41
|
+
"debugging" = 2
|
|
42
|
+
"code review" = 2
|
|
43
|
+
"documentation" = 1
|
|
44
|
+
"api" = 1
|
|
45
|
+
"database" = 1
|
|
46
|
+
"security" = 1
|
|
47
|
+
"design" = 1
|
|
48
|
+
|
|
49
|
+
[scoring]
|
|
50
|
+
recommend_at = 0.45 # combined score needed to be recommended
|
|
51
|
+
min_relevance = 0.3 # and at least this much keyword overlap with your work
|
|
52
|
+
overlap_at = 0.35 # description similarity that counts as a conflict with an installed skill
|
skillscout/scan.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Static safety checks on a skill folder. Flags things to read before installing; it can't prove a skill safe."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import re
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
HIGH, MEDIUM, LOW = "high", "medium", "low"
|
|
9
|
+
|
|
10
|
+
# (level, reason, regex, trigger words). Regexes run on lowercased text and only when a trigger word
|
|
11
|
+
# appears, which keeps scanning fast (case-insensitive regex disables Python's literal-prefix search).
|
|
12
|
+
PATTERNS = [
|
|
13
|
+
(HIGH, "pipes a download into a shell", r"(curl|wget)[^\n|]{0,200}\|\s*(sudo\s+)?(ba|z)?sh\b", ("curl", "wget")),
|
|
14
|
+
(HIGH, "runs sudo", r"\bsudo\s+\w", ("sudo",)),
|
|
15
|
+
(HIGH, "deletes recursively from a broad path", r"rm\s+-[a-z]*r[a-z]*f?\s+(/|~|\$home)(\s|$|/\*)", ("rm ",)),
|
|
16
|
+
(HIGH, "reads SSH keys or the keychain", r"~/\.ssh|\.ssh/id_|security\s+find-(generic|internet)-password|git credential fill",
|
|
17
|
+
(".ssh", "find-generic", "find-internet", "credential fill")),
|
|
18
|
+
(HIGH, "decodes and executes", r"base64\s+(-d|--decode)[^\n]{0,200}\|\s*(ba|z)?sh", ("base64",)),
|
|
19
|
+
(HIGH, "tells the agent to ignore its instructions",
|
|
20
|
+
r"ignore (all |any )?(previous|prior|above) instructions|disregard (your|the) (system|previous)", ("ignore", "disregard")),
|
|
21
|
+
(HIGH, "tells the agent to hide actions from the user",
|
|
22
|
+
r"without (telling|informing|asking) the user|(do not|don't|never) (tell|inform|show) the user (about|that you|what you)"
|
|
23
|
+
r"|(hide|conceal|keep) (this|it|these|that) (secret |hidden )?from the user", (" the user",)),
|
|
24
|
+
(HIGH, "uploads local files", r"curl[^\n]{0,200}?\s(-f|--data(-binary)?|-d)\s+@|scp\s+\S+\s+\S+@", ("curl", "scp ")),
|
|
25
|
+
(MEDIUM, "reads .env or API keys", r"cat\s+[^\n]{0,120}?\.env\b|printenv|env\s*\|\s*grep|(api|secret)_key\s*=\s*\$",
|
|
26
|
+
(".env", "printenv", "env", "_key")),
|
|
27
|
+
(MEDIUM, "pushes or force-pushes git", r"git\s+push\s+(-f|--force)", ("git push",)),
|
|
28
|
+
(MEDIUM, "edits shell profiles or Claude settings", r"(>>|>)\s*~/\.(zshrc|bashrc|profile|claude/settings)",
|
|
29
|
+
("zshrc", "bashrc", "profile", "claude/settings")),
|
|
30
|
+
(MEDIUM, "disables permission prompts", r"dangerously-?skip-?permissions|bypasspermissions", ("dangerously", "bypasspermissions")),
|
|
31
|
+
]
|
|
32
|
+
HIDDEN = re.compile("[\u200b\u200c\u2060\u202a-\u202e\u2066-\u2069]") # zero-width + bidi controls; not ZWJ (emoji) or BOM
|
|
33
|
+
SCRIPT_EXT = {".sh", ".py", ".js", ".cjs", ".mjs", ".ts", ".rb", ".ps1", ".cmd", ".bat", ""}
|
|
34
|
+
MAX_SCAN = 512_000 # bytes; bigger text files (minified bundles, data) are flagged unscanned
|
|
35
|
+
NETWORK = re.compile(r"\b(requests\.|urllib|fetch\(|https?://|socket\.|XMLHttpRequest|axios)")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Flag:
|
|
40
|
+
level: str
|
|
41
|
+
reason: str
|
|
42
|
+
where: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
COMPILED = [(level, reason, re.compile(pat), words) for level, reason, pat, words in PATTERNS]
|
|
46
|
+
MAX_FILES = 400 # per skill; a skill at a repo's root can otherwise mean scanning the whole repo
|
|
47
|
+
_file_cache: dict[tuple[str, float], list[tuple[str, str, int | None]]] = {}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _scan_file(f: Path) -> list[tuple[str, str, int | None]]:
|
|
51
|
+
"""(level, reason, line) findings for one file, memoised per run so nested skills don't rescan."""
|
|
52
|
+
key = (str(f), f.stat().st_mtime)
|
|
53
|
+
if key in _file_cache:
|
|
54
|
+
return _file_cache[key]
|
|
55
|
+
out: list[tuple[str, str, int | None]] = []
|
|
56
|
+
if f.stat().st_size > MAX_SCAN:
|
|
57
|
+
out.append((MEDIUM, "file too large to scan", None))
|
|
58
|
+
else:
|
|
59
|
+
try:
|
|
60
|
+
text = f.read_text(errors="strict")
|
|
61
|
+
except (UnicodeDecodeError, OSError):
|
|
62
|
+
out.append((MEDIUM, "bundled binary file", None))
|
|
63
|
+
else:
|
|
64
|
+
low = text.lower()
|
|
65
|
+
for level, reason, rx, words in COMPILED:
|
|
66
|
+
if any(w in low for w in words) and (m := rx.search(low)):
|
|
67
|
+
out.append((level, reason, low.count("\n", 0, m.start()) + 1))
|
|
68
|
+
if HIDDEN.search(text):
|
|
69
|
+
out.append((HIGH, "contains hidden or direction-changing Unicode", None))
|
|
70
|
+
if f.suffix in SCRIPT_EXT and f.name != "SKILL.md" and (f.suffix or text.startswith("#!")):
|
|
71
|
+
net = bool(NETWORK.search(text))
|
|
72
|
+
out.append((MEDIUM if net else LOW, "bundled script that makes network calls" if net else "bundled script", None))
|
|
73
|
+
if f.name in {"hooks.json", "settings.json"}:
|
|
74
|
+
out.append((MEDIUM, "defines hooks or settings", None))
|
|
75
|
+
_file_cache[key] = out
|
|
76
|
+
return out
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# Fingerprint of the rules and of what the clone keeps (github.py): a change to either invalidates
|
|
80
|
+
# every cached scan result.
|
|
81
|
+
RULES = hashlib.sha256(b"".join((Path(__file__).parent / f).read_bytes() for f in ("scan.py", "github.py"))).hexdigest()[:12]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def scan(skill_dir: Path, repo_root: Path | None = None) -> list[Flag]:
|
|
85
|
+
flags, files = [], 0
|
|
86
|
+
for f in sorted(skill_dir.rglob("*")):
|
|
87
|
+
if ".git" in f.relative_to(skill_dir).parts:
|
|
88
|
+
continue
|
|
89
|
+
rel = str(f.relative_to(skill_dir))
|
|
90
|
+
if f.is_symlink():
|
|
91
|
+
flags.append(Flag(MEDIUM, "symlink (could point outside the skill)", rel))
|
|
92
|
+
continue
|
|
93
|
+
if not f.is_file():
|
|
94
|
+
continue
|
|
95
|
+
files += 1
|
|
96
|
+
if files > MAX_FILES:
|
|
97
|
+
flags.append(Flag(MEDIUM, f"more than {MAX_FILES} files; the rest weren't scanned", "."))
|
|
98
|
+
break
|
|
99
|
+
flags += [Flag(level, reason, f"{rel}:{line}" if line else rel) for level, reason, line in _scan_file(f)]
|
|
100
|
+
if repo_root and not any((repo_root / n).exists() for n in ("LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING")):
|
|
101
|
+
flags.append(Flag(LOW, "repo has no licence file", "repo"))
|
|
102
|
+
return flags
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def worst(flags: list[Flag] | list[dict]) -> str | None:
|
|
106
|
+
levels = {f["level"] if isinstance(f, dict) else f.level for f in flags}
|
|
107
|
+
for level in (HIGH, MEDIUM, LOW):
|
|
108
|
+
if level in levels:
|
|
109
|
+
return level
|
|
110
|
+
return None
|
skillscout/schedule.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Weekly discovery on each platform. Writes the job's files, never turns them on."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
LABEL = "com.skillscout.weekly"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _cmd(exe: Path) -> list[str]:
|
|
11
|
+
return [str(exe), "-m", "skillscout.cli", "discover", "--notify"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def plan(platform: str, exe: Path, state: Path, home: Path, has_systemd: bool) -> tuple[dict[Path, str], str]:
|
|
15
|
+
"""Returns the files to write and what to tell the user. Nothing here touches disk."""
|
|
16
|
+
log = state / "weekly.log"
|
|
17
|
+
if platform == "darwin":
|
|
18
|
+
plist = home / "Library" / "LaunchAgents" / f"{LABEL}.plist"
|
|
19
|
+
args = "".join(f"<string>{a}</string>" for a in _cmd(exe))
|
|
20
|
+
text = f"""<?xml version="1.0" encoding="UTF-8"?>
|
|
21
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
22
|
+
<plist version="1.0"><dict>
|
|
23
|
+
<key>Label</key><string>{LABEL}</string>
|
|
24
|
+
<key>ProgramArguments</key><array>{args}</array>
|
|
25
|
+
<key>StartCalendarInterval</key><dict><key>Weekday</key><integer>1</integer><key>Hour</key><integer>10</integer><key>Minute</key><integer>0</integer></dict>
|
|
26
|
+
<key>StandardOutPath</key><string>{log}</string>
|
|
27
|
+
<key>StandardErrorPath</key><string>{log}</string>
|
|
28
|
+
</dict></plist>
|
|
29
|
+
"""
|
|
30
|
+
return {plist: text}, f"wrote {plist}\nturn it on with: launchctl load {plist}\nturn it off with: launchctl unload {plist}"
|
|
31
|
+
|
|
32
|
+
if platform.startswith("linux") and has_systemd:
|
|
33
|
+
unit = home / ".config" / "systemd" / "user"
|
|
34
|
+
service = f"""[Unit]
|
|
35
|
+
Description=skillscout weekly discovery
|
|
36
|
+
|
|
37
|
+
[Service]
|
|
38
|
+
Type=oneshot
|
|
39
|
+
ExecStart={" ".join(f'"{a}"' for a in _cmd(exe))}
|
|
40
|
+
StandardOutput=append:{log}
|
|
41
|
+
StandardError=append:{log}
|
|
42
|
+
"""
|
|
43
|
+
timer = """[Unit]
|
|
44
|
+
Description=Run skillscout discover every Monday at 10:00
|
|
45
|
+
|
|
46
|
+
[Timer]
|
|
47
|
+
OnCalendar=Mon *-*-* 10:00:00
|
|
48
|
+
Persistent=true
|
|
49
|
+
|
|
50
|
+
[Install]
|
|
51
|
+
WantedBy=timers.target
|
|
52
|
+
"""
|
|
53
|
+
files = {unit / "skillscout.service": service, unit / "skillscout.timer": timer}
|
|
54
|
+
return files, (f"wrote {unit}/skillscout.service and skillscout.timer\n"
|
|
55
|
+
"turn it on with: systemctl --user daemon-reload && systemctl --user enable --now skillscout.timer\n"
|
|
56
|
+
"turn it off with: systemctl --user disable --now skillscout.timer")
|
|
57
|
+
|
|
58
|
+
if platform in ("win32", "cygwin"):
|
|
59
|
+
tr = " ".join(f'\\"{a}\\"' if " " in a else a for a in _cmd(exe))
|
|
60
|
+
return {}, ("Windows has no file to write. Turn it on in a terminal with:\n"
|
|
61
|
+
f' schtasks /Create /SC WEEKLY /D MON /ST 10:00 /TN skillscout-weekly /TR "{tr}"\n'
|
|
62
|
+
"turn it off with: schtasks /Delete /TN skillscout-weekly /F")
|
|
63
|
+
|
|
64
|
+
line = f"0 10 * * 1 {' '.join(_cmd(exe))} >> {log} 2>&1"
|
|
65
|
+
return {}, f"no systemd found. Add this line with `crontab -e`:\n {line}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def write(files: dict[Path, str]) -> None:
|
|
69
|
+
for path, text in files.items():
|
|
70
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
path.write_text(text)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def here(state: Path) -> tuple[dict[Path, str], str]:
|
|
75
|
+
return plan(sys.platform, Path(sys.executable), state, Path.home(), shutil.which("systemctl") is not None)
|
skillscout/score.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Relevance to your profile, popularity, freshness, and overlap with skills you already have."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import re
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
WORD = re.compile(r"[a-z0-9]+")
|
|
8
|
+
STOP = set("a an and the of to for in on with when use used using or is are be this that it its your you any as by from".split())
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def tokens(text: str) -> set[str]:
|
|
12
|
+
return {w for w in WORD.findall(text.lower()) if w not in STOP and len(w) > 2}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def relevance(text: str, keywords: dict[str, float]) -> tuple[float, list[str]]:
|
|
16
|
+
"""Share of the best achievable keyword weight this text hits, capped so 3 strong matches reach 1."""
|
|
17
|
+
low = " " + " ".join(WORD.findall(text.lower())) + " "
|
|
18
|
+
hits = [k for k in keywords if f" {' '.join(WORD.findall(k.lower()))} " in low]
|
|
19
|
+
top = sorted(keywords.values(), reverse=True)[:3]
|
|
20
|
+
return min(sum(keywords[k] for k in hits) / max(sum(top), 1e-9), 1.0), hits
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def popularity(stars: int) -> float:
|
|
24
|
+
return min(math.log10(max(stars, 1)) / 4, 1.0) # 10k stars = 1.0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def freshness(pushed_at: str | None, now: datetime | None = None) -> float:
|
|
28
|
+
if not pushed_at:
|
|
29
|
+
return 0.0
|
|
30
|
+
now = now or datetime.now(timezone.utc)
|
|
31
|
+
days = (now - datetime.fromisoformat(pushed_at.replace("Z", "+00:00"))).days
|
|
32
|
+
return max(0.0, 1 - days / 365)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def combined(rel: float, pop: float, fresh: float, trusted: bool) -> float:
|
|
36
|
+
return round(min(0.65 * rel + 0.2 * pop + 0.15 * fresh + (0.1 if trusted else 0), 1.0), 3)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def similarity(a: str | set[str], b: str | set[str]) -> float:
|
|
40
|
+
ta = a if isinstance(a, set) else tokens(a)
|
|
41
|
+
tb = b if isinstance(b, set) else tokens(b)
|
|
42
|
+
return len(ta & tb) / len(ta | tb) if ta and tb else 0.0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_token_cache: dict[str, set[str]] = {}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def conflicts(name: str, description: str, installed: dict[str, str], overlap_at: float) -> list[str]:
|
|
49
|
+
"""Installed skills with the same name or a description that would trigger on the same prompts."""
|
|
50
|
+
out, mine = [], tokens(description)
|
|
51
|
+
for other, desc in installed.items():
|
|
52
|
+
short = other.split(":")[-1]
|
|
53
|
+
if short == name:
|
|
54
|
+
out.append(f"same name as installed `{other}`")
|
|
55
|
+
continue
|
|
56
|
+
theirs = _token_cache.get(desc)
|
|
57
|
+
if theirs is None:
|
|
58
|
+
theirs = _token_cache[desc] = tokens(desc)
|
|
59
|
+
if (s := similarity(mine, theirs)) >= overlap_at:
|
|
60
|
+
out.append(f"overlaps `{other}` ({s:.0%} similar)")
|
|
61
|
+
return out
|
skillscout/skill.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Reading SKILL.md files."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class Skill:
|
|
9
|
+
name: str
|
|
10
|
+
description: str
|
|
11
|
+
path: Path # directory holding SKILL.md
|
|
12
|
+
files: list[str] = field(default_factory=list)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_frontmatter(text: str) -> dict[str, str]:
|
|
16
|
+
"""Minimal YAML frontmatter reader: `key: value`, quoted values and folded (`>`, `|`) blocks."""
|
|
17
|
+
lines = text.splitlines()
|
|
18
|
+
if not lines or lines[0].strip() != "---":
|
|
19
|
+
return {}
|
|
20
|
+
meta, key, block = {}, None, []
|
|
21
|
+
for line in lines[1:]:
|
|
22
|
+
if line.strip() == "---":
|
|
23
|
+
break
|
|
24
|
+
if key and (line.startswith((" ", "\t")) or not line.strip()):
|
|
25
|
+
block.append(line.strip())
|
|
26
|
+
continue
|
|
27
|
+
if key:
|
|
28
|
+
meta[key], key, block = " ".join(b for b in block if b), None, []
|
|
29
|
+
if ":" in line and not line.startswith((" ", "\t")):
|
|
30
|
+
k, v = line.split(":", 1)
|
|
31
|
+
v = v.strip()
|
|
32
|
+
if v in (">", ">-", "|", "|-"):
|
|
33
|
+
key = k.strip()
|
|
34
|
+
else:
|
|
35
|
+
meta[k.strip()] = v.strip("\"'")
|
|
36
|
+
if key:
|
|
37
|
+
meta[key] = " ".join(b for b in block if b)
|
|
38
|
+
return meta
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def find_skills(root: Path) -> list[Skill]:
|
|
42
|
+
"""Every SKILL.md under root, skipping vendored and VCS folders and duplicate names."""
|
|
43
|
+
skills, seen = [], set()
|
|
44
|
+
for md in sorted(root.rglob("SKILL.md")):
|
|
45
|
+
if any(part in {".git", "node_modules", "dist", "build"} for part in md.parts) or md.is_symlink() or not md.is_file():
|
|
46
|
+
continue
|
|
47
|
+
meta = parse_frontmatter(md.read_text(errors="ignore"))
|
|
48
|
+
name = meta.get("name") or md.parent.name
|
|
49
|
+
if name in seen:
|
|
50
|
+
continue
|
|
51
|
+
seen.add(name)
|
|
52
|
+
files = sorted(str(p.relative_to(md.parent)) for p in md.parent.rglob("*") if p.is_file() and not p.is_symlink())
|
|
53
|
+
skills.append(Skill(name, meta.get("description", ""), md.parent, files))
|
|
54
|
+
return skills
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: skillscout-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Keep your agent's skills current: weekly discovery, upstream diffs, safety scans, pinned installs
|
|
5
|
+
Author: Akshit Gupta
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://akshit9162.github.io/skillscout
|
|
8
|
+
Project-URL: Source, https://github.com/akshit9162/skillscout
|
|
9
|
+
Project-URL: Issues, https://github.com/akshit9162/skillscout/issues
|
|
10
|
+
Project-URL: Report, https://akshit9162.github.io/skillscout/report.html
|
|
11
|
+
Keywords: claude,claude-code,agent-skills,security,supply-chain,llm
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Security
|
|
21
|
+
Classifier: Topic :: Software Development
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
<p align="center">
|
|
30
|
+
<img src="https://raw.githubusercontent.com/akshit9162/skillscout/main/docs/assets/header.svg" width="760" alt="skillscout: keep your agent's skills current, safely" />
|
|
31
|
+
</p>
|
|
32
|
+
|
|
33
|
+
<p align="center">
|
|
34
|
+
<a href="https://akshit9162.github.io/skillscout">Website</a>  · 
|
|
35
|
+
<a href="https://akshit9162.github.io/skillscout/report.html">Report: what's inside 6,726 skills</a>  · 
|
|
36
|
+
<a href="#install">Install</a>  · 
|
|
37
|
+
<a href="#a-week-with-skillscout">A week with it</a>  · 
|
|
38
|
+
<a href="#how-it-works">How it works</a>  · 
|
|
39
|
+
<a href="#what-gets-flagged">What gets flagged</a>
|
|
40
|
+
</p>
|
|
41
|
+
|
|
42
|
+
<p align="center">
|
|
43
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT licence" /></a>
|
|
44
|
+
<a href="https://github.com/akshit9162/skillscout/actions/workflows/ci.yml"><img src="https://github.com/akshit9162/skillscout/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
|
|
45
|
+
<img src="https://img.shields.io/badge/python-3.11%2B-blue.svg" alt="Python 3.11+" />
|
|
46
|
+
<img src="https://img.shields.io/badge/dependencies-0-brightgreen.svg" alt="No dependencies" />
|
|
47
|
+
</p>
|
|
48
|
+
|
|
49
|
+
<p align="center">
|
|
50
|
+
<img src="https://raw.githubusercontent.com/akshit9162/skillscout/main/docs/assets/demo.svg" width="760" alt="Terminal demo: discover finds 216 recommended skills with 89 flagged high-risk; a high-risk skill is refused without --accept-risk; a low-risk one installs pinned to a commit" />
|
|
51
|
+
</p>
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
claude plugin marketplace add akshit9162/skillscout && claude plugin install skillscout@skillscout
|
|
55
|
+
# or the CLI on its own
|
|
56
|
+
pipx install git+https://github.com/akshit9162/skillscout
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
AI tooling moves weekly. New skills, plugins and agent workflows land on GitHub every day, the ones you
|
|
60
|
+
installed keep changing upstream, and last month's best practice is already stale. Nobody has time to
|
|
61
|
+
watch hundreds of repos.
|
|
62
|
+
|
|
63
|
+
**skillscout keeps your agent current.** Once a week it checks the skill ecosystem against what *you*
|
|
64
|
+
work on, tells you what's new and worth having, and shows you exactly what changed in the skills you
|
|
65
|
+
already use.
|
|
66
|
+
|
|
67
|
+
Keeping up fast is only useful if it's safe. A skill is a set of instructions your agent follows
|
|
68
|
+
**with your permissions**, so skillscout scans every file for dangerous patterns, installs nothing without
|
|
69
|
+
your approval, pins each install to an exact commit, and re-scans every update before it can replace anything.
|
|
70
|
+
|
|
71
|
+
In one run over the public ecosystem it found **6,726 skills in 95 repos**. 198 had at least one high-risk flag,
|
|
72
|
+
mostly `sudo` and `curl | sh` install steps, plus prompt-injection-style text and hidden Unicode.
|
|
73
|
+
The [report](https://akshit9162.github.io/skillscout/report.html) reads every one of the scariest flags: they were defenders.
|
|
74
|
+
|
|
75
|
+
It's a zero-dependency Python CLI, and it also ships as a skill, so your agent can run the whole loop for you
|
|
76
|
+
and still ask before every install.
|
|
77
|
+
|
|
78
|
+
## A week with skillscout
|
|
79
|
+
|
|
80
|
+
Example session (counts vary week to week):
|
|
81
|
+
|
|
82
|
+
```text
|
|
83
|
+
Monday 10:00 skillscout discover --notify (weekly, via schedule)
|
|
84
|
+
→ "4 new, 23 recommended, 11 flagged high-risk"
|
|
85
|
+
You skillscout show github/awesome-copilot:eval-driven-dev
|
|
86
|
+
→ flags with file:line, the full SKILL.md, overlaps with what you have
|
|
87
|
+
You skillscout approve eval-driven-dev → copied, pinned to its commit
|
|
88
|
+
Later skillscout updates → upstream changed it; here's the diff
|
|
89
|
+
You skillscout approve eval-driven-dev --update → re-scanned, then replaced
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Install
|
|
93
|
+
|
|
94
|
+
### As a Claude Code plugin (the agent drives it)
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
claude plugin marketplace add akshit9162/skillscout
|
|
98
|
+
claude plugin install skillscout@skillscout
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Then ask: *"find me skills for my Python and React work"*. The agent installs the CLI, runs the search,
|
|
102
|
+
summarises the report and shows you each skill's flags before asking to install it.
|
|
103
|
+
|
|
104
|
+
### As a CLI
|
|
105
|
+
|
|
106
|
+
```sh
|
|
107
|
+
pipx install git+https://github.com/akshit9162/skillscout # or: uv tool install git+https://...
|
|
108
|
+
skillscout init # writes ~/.skillscout/profile.toml
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Edit `[keywords]` in `~/.skillscout/profile.toml` with the words from your own work, then:
|
|
112
|
+
|
|
113
|
+
```sh
|
|
114
|
+
skillscout discover # search, scan, score; writes ~/.skillscout/reports/<date>.md
|
|
115
|
+
skillscout list # ranked; REC = recommended
|
|
116
|
+
skillscout show <id> # every flag plus the full SKILL.md, before you decide
|
|
117
|
+
skillscout approve <id> # copy into ~/.claude/skills and pin the commit
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`<id>` is `owner/repo:skill-name`. The bare skill name works when it's unique.
|
|
121
|
+
|
|
122
|
+
## How it works
|
|
123
|
+
|
|
124
|
+
| Step | What happens |
|
|
125
|
+
|---|---|
|
|
126
|
+
| **Discover** | Runs your GitHub searches (topics like `claude-skills`, `agent-skills`) plus your trusted repos, then follows every repo linked from curated lists (your `lists`, plus any `awesome-*` repo it finds). Drops archived, stale (no push in a year), unpopular and huge repos. |
|
|
127
|
+
| **Clone** | Sparse, shallow clones: only folders that contain a `SKILL.md`, minus media and archives, 16 repos at a time. Repos with no push since the last run aren't fetched again. Repo code is never run. |
|
|
128
|
+
| **Scan** | Static checks on every file in the skill, reported with file and line ([below](#what-gets-flagged)). A skill whose folder (git tree hash) and the scanner's rules are both unchanged keeps its last result instead of being read again. |
|
|
129
|
+
| **Score** | 65% relevance to your keywords, 20% stars, 15% freshness, plus a bonus for trusted repos. |
|
|
130
|
+
| **Conflicts** | Compared against your `~/.claude/skills` and plugin skills: same name, or a description close enough that both would fire on the same prompt. |
|
|
131
|
+
| **Report** | What's new since your last run, recommended skills, and relevant ones that are blocked and why. |
|
|
132
|
+
| **Approve** | You pick one. It's copied (symlinks dropped) and pinned in `~/.skillscout/skills.lock.json` with its commit and a content hash. |
|
|
133
|
+
| **Update** | `skillscout updates` diffs each pinned skill against upstream and applies nothing. Taking an update re-runs the scan. |
|
|
134
|
+
|
|
135
|
+
## What gets flagged
|
|
136
|
+
|
|
137
|
+
| Level | Patterns |
|
|
138
|
+
|---|---|
|
|
139
|
+
| **High**, blocks install | Download piped into a shell · `sudo` · broad `rm -rf` · reading SSH keys or the keychain · decode-and-execute · uploading local files · hidden or direction-changing Unicode · "ignore previous instructions" · "don't tell the user" |
|
|
140
|
+
| **Medium** | Reading `.env` or API keys · force pushes · editing shell profiles or Claude settings · skipping permission prompts · hooks · scripts that make network calls · symlinks · binaries |
|
|
141
|
+
| **Low** | Other bundled scripts · no licence |
|
|
142
|
+
|
|
143
|
+
High-risk skills need `--accept-risk`, which the bundled skill tells the agent never to pass on its own.
|
|
144
|
+
|
|
145
|
+
## Safety rails
|
|
146
|
+
|
|
147
|
+
- Nothing installs without an explicit `approve`.
|
|
148
|
+
- `approve` never overwrites a folder skillscout didn't install, and never follows symlinks.
|
|
149
|
+
- Every install is pinned to a commit and a content hash.
|
|
150
|
+
- Updates are a diff first, and re-scanned before they replace anything. The test suite includes an upstream
|
|
151
|
+
update that slips in `sudo rm -rf /`, and checks that re-approval refuses it.
|
|
152
|
+
|
|
153
|
+
## Every command
|
|
154
|
+
|
|
155
|
+
| Command | Does |
|
|
156
|
+
|---|---|
|
|
157
|
+
| `skillscout init` | Copy the example profile to `~/.skillscout/profile.toml` |
|
|
158
|
+
| `skillscout discover [--full] [--notify]` | Search, scan, score, write the report. `--full` ignores the caches; `--notify` sends a desktop notification (macOS, or Linux with `notify-send`) |
|
|
159
|
+
| `skillscout list [--all] [-n N]` | Ranked candidates |
|
|
160
|
+
| `skillscout show <id>` | Flags, conflicts and the full SKILL.md |
|
|
161
|
+
| `skillscout approve <id> [--update] [--accept-risk]` | Install and pin |
|
|
162
|
+
| `skillscout installed` · `skillscout remove <id>` | Pinned skills · uninstall |
|
|
163
|
+
| `skillscout updates` | Diff pinned skills against upstream |
|
|
164
|
+
| `skillscout schedule` | Set up weekly discovery: launchd on macOS, a systemd timer on Linux (cron line without systemd), a `schtasks` command on Windows. It prints the one command that turns it on |
|
|
165
|
+
|
|
166
|
+
Set `GITHUB_TOKEN`, or just be logged in with the GitHub CLI (`gh auth login`), for higher API rate limits (5,000 requests an hour instead of 60). Following lists costs one lookup per linked repo: up to 300 a run with a token, 40 without. A token is optional: without it, a rate limit means waiting rather than failing. A weekly run with few upstream changes takes about 25 seconds; the first run takes a few minutes. Scheduled jobs don't see your shell's environment, so add the token to the job if you want it there.
|
|
167
|
+
|
|
168
|
+
## Limits
|
|
169
|
+
|
|
170
|
+
- The scanner catches known patterns. It can't prove a skill is safe, so read `show` before you approve.
|
|
171
|
+
- Relevance is keyword matching against your profile, not semantic search. Tune the weights.
|
|
172
|
+
- Every installed skill's description loads into every agent session. Keep the total to a few dozen.
|
|
173
|
+
|
|
174
|
+
## Development
|
|
175
|
+
|
|
176
|
+
```sh
|
|
177
|
+
python3 -m venv .venv && .venv/bin/pip install -e ".[dev]"
|
|
178
|
+
.venv/bin/python -m pytest -q
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
## License
|
|
182
|
+
|
|
183
|
+
MIT
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
skillscout/__init__.py,sha256=MMpOs56DKWVinCMwDc8ddUkt-zGT8m736kRa8bo4xVg,122
|
|
2
|
+
skillscout/cli.py,sha256=hjiYrYK-Cw0i7f0Vc7UunSuJYVnaZmuTDxJrmZpC3fA,4819
|
|
3
|
+
skillscout/core.py,sha256=cNdIa9DAkRkTonYn5o-iiN2hsfOjiwX4N2kYR06CjPs,14275
|
|
4
|
+
skillscout/github.py,sha256=y1ykP_eGbm0bv3Z7c8tIQfBPLDaUpn-SMrquart1cIU,7598
|
|
5
|
+
skillscout/profile.example.toml,sha256=9M4aoxW1ZtQDrjO4F9yt0xi8b0js8JdwF4JzI9poqZ8,1635
|
|
6
|
+
skillscout/scan.py,sha256=H-oq97lDLe-UxwTt3Q4LHwMh52ho-Vl54v4d_BYOf_I,5797
|
|
7
|
+
skillscout/schedule.py,sha256=PUfQaUc4zbGxPd3A_uBDcp3WClL8jUHLHHHrfQ1GFaM,3112
|
|
8
|
+
skillscout/score.py,sha256=Yul-87PX2kuzBMQt2LqPk--n8gUurLYPLXX2ZkPbllU,2436
|
|
9
|
+
skillscout/skill.py,sha256=kycyjJusZiS2ObPaolJE_z0ZVpMYAmaj2MVAVldxbzs,1978
|
|
10
|
+
skillscout_cli-0.1.0.dist-info/licenses/LICENSE,sha256=hes6NS-2Pmc39Kvspmg8gfCxag_lZWOMKAgV_VaCgJ4,1069
|
|
11
|
+
skillscout_cli-0.1.0.dist-info/METADATA,sha256=hj607jkTQjsjpjs3x9VBsY9-dugJ6C3CZ_n-7m9mO1E,10158
|
|
12
|
+
skillscout_cli-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
13
|
+
skillscout_cli-0.1.0.dist-info/entry_points.txt,sha256=xWKgTnjOc0uMtJAK1Bnm3Zh6hIaMtnAwghR9V1a1W0A,51
|
|
14
|
+
skillscout_cli-0.1.0.dist-info/top_level.txt,sha256=Kh9gUf5ykVewMvBCZ5v2GLGAxTV29sxkjC6bmnAIoYc,11
|
|
15
|
+
skillscout_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Akshit Gupta
|
|
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
|
+
skillscout
|