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 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> &ensp;·&ensp;
35
+ <a href="https://akshit9162.github.io/skillscout/report.html">Report: what's inside 6,726 skills</a> &ensp;·&ensp;
36
+ <a href="#install">Install</a> &ensp;·&ensp;
37
+ <a href="#a-week-with-skillscout">A week with it</a> &ensp;·&ensp;
38
+ <a href="#how-it-works">How it works</a> &ensp;·&ensp;
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ skillscout = skillscout.cli:main
@@ -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