skill-customs 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.
- skill_customs/__init__.py +77 -0
- skill_customs/attribution.py +32 -0
- skill_customs/cli.py +266 -0
- skill_customs/discover.py +170 -0
- skill_customs/errors.py +37 -0
- skill_customs/fetch.py +168 -0
- skill_customs/frontmatter.py +75 -0
- skill_customs/hashing.py +20 -0
- skill_customs/install.py +237 -0
- skill_customs/licenses.py +152 -0
- skill_customs/lockfile.py +137 -0
- skill_customs/policy.py +49 -0
- skill_customs/py.typed +0 -0
- skill_customs/scan/__init__.py +233 -0
- skill_customs/scan/rules.py +222 -0
- skill_customs-0.1.0.dist-info/METADATA +208 -0
- skill_customs-0.1.0.dist-info/RECORD +20 -0
- skill_customs-0.1.0.dist-info/WHEEL +4 -0
- skill_customs-0.1.0.dist-info/entry_points.txt +2 -0
- skill_customs-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""skill-customs: the lockfile, license, and pinning layer for agent skills.
|
|
2
|
+
|
|
3
|
+
Fetching never runs anything from a skill. A passing scan means no known signal was
|
|
4
|
+
found. It does not mean a skill is safe.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .attribution import render_attribution
|
|
8
|
+
from .discover import Candidate, SkillFile, discover, list_skill_paths, search_skill_files
|
|
9
|
+
from .errors import (
|
|
10
|
+
ApprovalError,
|
|
11
|
+
FetchError,
|
|
12
|
+
LicenseRejected,
|
|
13
|
+
LockfileError,
|
|
14
|
+
NotASkill,
|
|
15
|
+
SkillCustomsError,
|
|
16
|
+
UnsafeArchive,
|
|
17
|
+
)
|
|
18
|
+
from .fetch import GitHubClient, is_full_sha
|
|
19
|
+
from .frontmatter import SkillMetadata, read_skill_metadata
|
|
20
|
+
from .hashing import content_hash
|
|
21
|
+
from .install import PinResult, approve, pin, pin_local, remove, skill_dir, verify
|
|
22
|
+
from .licenses import LicenseVerdict, check_license
|
|
23
|
+
from .lockfile import Approval, LockEntry, Lockfile
|
|
24
|
+
from .policy import Policy
|
|
25
|
+
from .scan import (
|
|
26
|
+
BuiltinScanner,
|
|
27
|
+
CommandScanner,
|
|
28
|
+
Finding,
|
|
29
|
+
Scanner,
|
|
30
|
+
ScanReport,
|
|
31
|
+
decide_trust,
|
|
32
|
+
scan,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__version__ = "0.1.0"
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"Approval",
|
|
39
|
+
"ApprovalError",
|
|
40
|
+
"BuiltinScanner",
|
|
41
|
+
"Candidate",
|
|
42
|
+
"CommandScanner",
|
|
43
|
+
"FetchError",
|
|
44
|
+
"Finding",
|
|
45
|
+
"GitHubClient",
|
|
46
|
+
"LicenseRejected",
|
|
47
|
+
"LicenseVerdict",
|
|
48
|
+
"LockEntry",
|
|
49
|
+
"Lockfile",
|
|
50
|
+
"LockfileError",
|
|
51
|
+
"NotASkill",
|
|
52
|
+
"PinResult",
|
|
53
|
+
"Policy",
|
|
54
|
+
"ScanReport",
|
|
55
|
+
"Scanner",
|
|
56
|
+
"SkillCustomsError",
|
|
57
|
+
"SkillFile",
|
|
58
|
+
"SkillMetadata",
|
|
59
|
+
"UnsafeArchive",
|
|
60
|
+
"__version__",
|
|
61
|
+
"approve",
|
|
62
|
+
"check_license",
|
|
63
|
+
"content_hash",
|
|
64
|
+
"decide_trust",
|
|
65
|
+
"discover",
|
|
66
|
+
"is_full_sha",
|
|
67
|
+
"list_skill_paths",
|
|
68
|
+
"pin",
|
|
69
|
+
"pin_local",
|
|
70
|
+
"read_skill_metadata",
|
|
71
|
+
"remove",
|
|
72
|
+
"render_attribution",
|
|
73
|
+
"scan",
|
|
74
|
+
"search_skill_files",
|
|
75
|
+
"skill_dir",
|
|
76
|
+
"verify",
|
|
77
|
+
]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Generate THIRD_PARTY_SKILLS.md from the lockfile, so it can't drift from what's installed."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .lockfile import Lockfile
|
|
6
|
+
|
|
7
|
+
HEADER = """# Third-party skills
|
|
8
|
+
|
|
9
|
+
Generated by `skill-customs attribution` from `skills.lock`. Don't edit by hand.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _cell(text: str) -> str:
|
|
14
|
+
"""Skill names come from outside. Keep them from breaking out of a table cell."""
|
|
15
|
+
return text.replace("|", "\\|").replace("\n", " ")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def render_attribution(lock: Lockfile) -> str:
|
|
19
|
+
entries = [lock.entries[key] for key in sorted(lock.entries) if lock.entries[key].host != "local"]
|
|
20
|
+
if not entries:
|
|
21
|
+
return HEADER + "\nNo third-party skills are installed.\n"
|
|
22
|
+
lines = [HEADER, "| Skill | Source | Commit | License | Commercial use | Trust |", "|---|---|---|---|---|---|"]
|
|
23
|
+
for entry in entries:
|
|
24
|
+
source = f"https://github.com/{entry.repo}"
|
|
25
|
+
lines.append(
|
|
26
|
+
f"| {_cell(entry.skill_id)} | {source} | `{entry.commit[:12]}` | {_cell(entry.license_spdx or 'unknown')} "
|
|
27
|
+
f"| {entry.commercial_use} | {entry.trust} |"
|
|
28
|
+
)
|
|
29
|
+
restricted = [e.skill_id for e in entries if e.commercial_use != "allowed"]
|
|
30
|
+
if restricted:
|
|
31
|
+
lines += ["", "## Check before commercial use", ""] + [f"- {_cell(skill)}" for skill in restricted]
|
|
32
|
+
return "\n".join(lines) + "\n"
|
skill_customs/cli.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""The skill-customs command. Prints only sanitized text, because skill content is untrusted."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .attribution import render_attribution
|
|
15
|
+
from .discover import discover
|
|
16
|
+
from .errors import SkillCustomsError
|
|
17
|
+
from .fetch import GitHubClient
|
|
18
|
+
from .install import approve, pin, pin_local, remove, skill_dir, verify
|
|
19
|
+
from .licenses import check_license
|
|
20
|
+
from .lockfile import Lockfile
|
|
21
|
+
from .policy import Policy
|
|
22
|
+
from .scan import ScanReport, decide_trust, scan
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _out(text: str = "") -> None:
|
|
26
|
+
stream = sys.stdout
|
|
27
|
+
encoding = stream.encoding or "utf-8"
|
|
28
|
+
stream.write(text.encode(encoding, errors="backslashreplace").decode(encoding) + "\n")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _print_report(report: ScanReport) -> None:
|
|
32
|
+
for finding in report.findings:
|
|
33
|
+
where = f"{finding.file}:{finding.line}" if finding.line else finding.file
|
|
34
|
+
_out(f" {finding.severity:<6} {where} [{finding.rule}] {finding.message}")
|
|
35
|
+
if finding.excerpt:
|
|
36
|
+
_out(f" {finding.excerpt}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def cmd_scan(args: argparse.Namespace, policy: Policy) -> int:
|
|
40
|
+
report = scan(Path(args.path), policy)
|
|
41
|
+
if args.json:
|
|
42
|
+
_out(
|
|
43
|
+
json.dumps(
|
|
44
|
+
{
|
|
45
|
+
"verdict": report.verdict,
|
|
46
|
+
"trust": decide_trust(report),
|
|
47
|
+
"executable_files": report.executable_files,
|
|
48
|
+
"findings": [{**f.to_dict(), "message": f.message} for f in report.findings],
|
|
49
|
+
},
|
|
50
|
+
ensure_ascii=False,
|
|
51
|
+
indent=2,
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
else:
|
|
55
|
+
_print_report(report)
|
|
56
|
+
counts = f"{report.files_scanned} files, {len(report.findings)} findings"
|
|
57
|
+
_out(f"{report.verdict}: {counts}. Would be: {decide_trust(report)}.")
|
|
58
|
+
if report.verdict == "pass":
|
|
59
|
+
_out("Pass means no known signal was found. It doesn't mean this skill is safe.")
|
|
60
|
+
return report.exit_code
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def cmd_license(args: argparse.Namespace, policy: Policy) -> int:
|
|
64
|
+
verdict = check_license(Path(args.path), policy, repo_root=Path(args.repo_root) if args.repo_root else None)
|
|
65
|
+
_out(f"{'allowed' if verdict.allowed else 'rejected'}: {verdict.spdx or 'unknown'}. {verdict.reason}")
|
|
66
|
+
_out(f"Commercial use: {verdict.commercial_use}. Evidence: {verdict.evidence}.")
|
|
67
|
+
return 0 if verdict.allowed else 1
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def cmd_resolve(args: argparse.Namespace, policy: Policy) -> int:
|
|
71
|
+
repo, _, ref = args.target.partition("@")
|
|
72
|
+
_out(GitHubClient().resolve_ref(repo, ref or "HEAD"))
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def cmd_add(args: argparse.Namespace, policy: Policy) -> int:
|
|
77
|
+
lock = Lockfile.load(args.lock)
|
|
78
|
+
result = pin(args.repo, args.commit, path=args.path, dest=Path(args.dest), policy=policy)
|
|
79
|
+
lock.add(result.entry).save()
|
|
80
|
+
_print_report(result.report)
|
|
81
|
+
_out(f"{result.entry.skill_id}: {result.entry.trust} ({result.license.spdx})")
|
|
82
|
+
_out(f"Location: {skill_dir(result.entry, Path(args.dest))}")
|
|
83
|
+
if result.entry.trust == "quarantined":
|
|
84
|
+
_out(f"A person must review it, then run: skill-customs approve {result.entry.skill_id} --by <your name>")
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def cmd_add_local(args: argparse.Namespace, policy: Policy) -> int:
|
|
89
|
+
lock = Lockfile.load(args.lock)
|
|
90
|
+
result = pin_local(Path(args.path), license_spdx=args.license, dest=Path(args.dest), policy=policy)
|
|
91
|
+
lock.add(result.entry).save()
|
|
92
|
+
_print_report(result.report)
|
|
93
|
+
_out(f"{result.entry.skill_id}: quarantined until a person approves it.")
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def cmd_approve(args: argparse.Namespace, policy: Policy) -> int:
|
|
98
|
+
"""People only. It refuses to run without a terminal, and asks for the name to be typed back."""
|
|
99
|
+
if not sys.stdin.isatty():
|
|
100
|
+
_out("approve needs a person at a terminal, so it won't run from a script or an agent.")
|
|
101
|
+
return 2
|
|
102
|
+
lock = Lockfile.load(args.lock)
|
|
103
|
+
entry = lock.get(args.skill_id)
|
|
104
|
+
_out(f"Approving {entry.skill_id}: scan verdict {entry.scan.get('verdict')}, license {entry.license_spdx}.")
|
|
105
|
+
_out(f"Review the files in {skill_dir(entry, Path(args.dest))} first.")
|
|
106
|
+
if input("Type the skill ID to confirm: ").strip() != args.skill_id:
|
|
107
|
+
_out("It didn't match, so nothing was approved.")
|
|
108
|
+
return 1
|
|
109
|
+
approved = approve(
|
|
110
|
+
lock, args.skill_id, by=args.by, dest=Path(args.dest), accept_blocking_findings=args.accept_findings
|
|
111
|
+
)
|
|
112
|
+
lock.save()
|
|
113
|
+
_out(f"{approved.skill_id}: verified by {args.by}. Any later change voids this.")
|
|
114
|
+
return 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def cmd_verify(args: argparse.Namespace, policy: Policy) -> int:
|
|
118
|
+
lock = Lockfile.load(args.lock)
|
|
119
|
+
problems = verify(lock, dest=Path(args.dest), policy=policy)
|
|
120
|
+
for problem in problems:
|
|
121
|
+
_out(f" {problem}")
|
|
122
|
+
if problems:
|
|
123
|
+
_out(f"Failed: {len(problems)} problem(s).")
|
|
124
|
+
return 1
|
|
125
|
+
_out(f"OK: {len(lock.entries)} skill(s) match {args.lock}.")
|
|
126
|
+
return 0
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def cmd_list(args: argparse.Namespace, policy: Policy) -> int:
|
|
130
|
+
lock = Lockfile.load(args.lock)
|
|
131
|
+
if not lock.entries:
|
|
132
|
+
_out("No skills are locked.")
|
|
133
|
+
for skill_id, entry in sorted(lock.entries.items()):
|
|
134
|
+
commit = entry.commit[:12] or "local"
|
|
135
|
+
_out(f"{entry.trust:<12} {entry.license_spdx or 'unknown':<14} {commit:<13} {skill_id}")
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def cmd_remove(args: argparse.Namespace, policy: Policy) -> int:
|
|
140
|
+
lock = Lockfile.load(args.lock)
|
|
141
|
+
remove(lock, args.skill_id, dest=Path(args.dest))
|
|
142
|
+
lock.save()
|
|
143
|
+
_out(f"Removed {args.skill_id}.")
|
|
144
|
+
return 0
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def cmd_attribution(args: argparse.Namespace, policy: Policy) -> int:
|
|
148
|
+
text = render_attribution(Lockfile.load(args.lock))
|
|
149
|
+
if args.check:
|
|
150
|
+
target = Path(args.check)
|
|
151
|
+
current = target.read_text(encoding="utf-8") if target.is_file() else ""
|
|
152
|
+
if current != text:
|
|
153
|
+
_out(f"{target} is out of date. Regenerate it with: skill-customs attribution --out {target}")
|
|
154
|
+
return 1
|
|
155
|
+
_out(f"{target} is up to date.")
|
|
156
|
+
elif args.out:
|
|
157
|
+
Path(args.out).write_text(text, encoding="utf-8", newline="\n")
|
|
158
|
+
else:
|
|
159
|
+
_out(text.rstrip())
|
|
160
|
+
return 0
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def cmd_discover(args: argparse.Namespace, policy: Policy) -> int:
|
|
164
|
+
if args.categories:
|
|
165
|
+
categories = yaml.safe_load(Path(args.categories).read_text(encoding="utf-8")) or {}
|
|
166
|
+
results = {
|
|
167
|
+
name: [c.to_dict() for c in discover(query, policy=policy, top=args.top)]
|
|
168
|
+
for name, query in sorted(categories.items())
|
|
169
|
+
}
|
|
170
|
+
text = json.dumps(results, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
|
|
171
|
+
if args.out:
|
|
172
|
+
Path(args.out).write_text(text, encoding="utf-8", newline="\n")
|
|
173
|
+
_out(
|
|
174
|
+
f"Wrote {sum(len(v) for v in results.values())} candidates in {len(results)} categories to {args.out}."
|
|
175
|
+
)
|
|
176
|
+
else:
|
|
177
|
+
_out(text.rstrip())
|
|
178
|
+
return 0
|
|
179
|
+
if not args.query:
|
|
180
|
+
_out("Give a search query, or --categories with a file mapping each category to a query.")
|
|
181
|
+
return 2
|
|
182
|
+
found = discover(args.query, policy=policy, top=args.top)
|
|
183
|
+
if args.json:
|
|
184
|
+
_out(json.dumps([c.to_dict() for c in found], ensure_ascii=False, indent=2))
|
|
185
|
+
return 0
|
|
186
|
+
for candidate in found:
|
|
187
|
+
flag = "" if candidate.license_allowed else " [license not on the allowlist]"
|
|
188
|
+
_out(f"{candidate.score:>7.2f} {candidate.stars:>7} {candidate.repo} {candidate.license_spdx}{flag}")
|
|
189
|
+
_out("These are candidates. Nothing was installed, and ranking says nothing about safety.")
|
|
190
|
+
return 0
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
194
|
+
parser = argparse.ArgumentParser(prog="skill-customs", description=__doc__)
|
|
195
|
+
parser.add_argument("--version", action="version", version=f"skill-customs {__version__}")
|
|
196
|
+
parser.add_argument("--policy", help="Policy file. Default: skill-customs.yaml, if it exists")
|
|
197
|
+
parser.add_argument("--lock", default="skills.lock")
|
|
198
|
+
parser.add_argument("--dest", default="skills", help="Folder that holds installed/ and quarantine/")
|
|
199
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
200
|
+
|
|
201
|
+
item = sub.add_parser("scan", help="Scan a skill folder. Exit 0 pass, 1 review, 2 block")
|
|
202
|
+
item.add_argument("path")
|
|
203
|
+
item.add_argument("--json", action="store_true")
|
|
204
|
+
item.set_defaults(func=cmd_scan)
|
|
205
|
+
|
|
206
|
+
item = sub.add_parser("license", help="Check a skill folder against the allowlist")
|
|
207
|
+
item.add_argument("path")
|
|
208
|
+
item.add_argument("--repo-root", help="Also read this folder's license file")
|
|
209
|
+
item.set_defaults(func=cmd_license)
|
|
210
|
+
|
|
211
|
+
item = sub.add_parser("resolve", help="Print the commit SHA for owner/repo@ref. Installs nothing")
|
|
212
|
+
item.add_argument("target")
|
|
213
|
+
item.set_defaults(func=cmd_resolve)
|
|
214
|
+
|
|
215
|
+
item = sub.add_parser("add", help="Pin a skill from GitHub by full commit SHA")
|
|
216
|
+
item.add_argument("repo")
|
|
217
|
+
item.add_argument("--commit", required=True)
|
|
218
|
+
item.add_argument("--path", default="", help="Skill folder inside the repository")
|
|
219
|
+
item.set_defaults(func=cmd_add)
|
|
220
|
+
|
|
221
|
+
item = sub.add_parser("add-local", help="Add a skill written here. Always quarantined")
|
|
222
|
+
item.add_argument("path")
|
|
223
|
+
item.add_argument("--license", required=True, help="SPDX ID you are releasing it under")
|
|
224
|
+
item.set_defaults(func=cmd_add_local)
|
|
225
|
+
|
|
226
|
+
item = sub.add_parser("approve", help="Approve a skill, as a person at a terminal")
|
|
227
|
+
item.add_argument("skill_id")
|
|
228
|
+
item.add_argument("--by", required=True)
|
|
229
|
+
item.add_argument("--accept-findings", action="store_true", help="Approve despite blocking findings")
|
|
230
|
+
item.set_defaults(func=cmd_approve)
|
|
231
|
+
|
|
232
|
+
sub.add_parser("verify", help="Check installed skills against skills.lock").set_defaults(func=cmd_verify)
|
|
233
|
+
sub.add_parser("list", help="List locked skills").set_defaults(func=cmd_list)
|
|
234
|
+
|
|
235
|
+
item = sub.add_parser("remove", help="Remove a skill and its lock entry")
|
|
236
|
+
item.add_argument("skill_id")
|
|
237
|
+
item.set_defaults(func=cmd_remove)
|
|
238
|
+
|
|
239
|
+
item = sub.add_parser("attribution", help="Generate THIRD_PARTY_SKILLS.md")
|
|
240
|
+
item.add_argument("--out")
|
|
241
|
+
item.add_argument("--check", help="Fail if this file isn't up to date")
|
|
242
|
+
item.set_defaults(func=cmd_attribution)
|
|
243
|
+
|
|
244
|
+
item = sub.add_parser("discover", help="Rank candidate repositories. Installs nothing")
|
|
245
|
+
item.add_argument("query", nargs="?")
|
|
246
|
+
item.add_argument("--categories", help="YAML file mapping each category to a search query")
|
|
247
|
+
item.add_argument("--out", help="With --categories: write the JSON here")
|
|
248
|
+
item.add_argument("--top", type=int, default=40, help="Keep this many per category")
|
|
249
|
+
item.add_argument("--json", action="store_true")
|
|
250
|
+
item.set_defaults(func=cmd_discover)
|
|
251
|
+
return parser
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
255
|
+
args = build_parser().parse_args(argv)
|
|
256
|
+
try:
|
|
257
|
+
policy = Policy.load(Path(args.policy) if args.policy else None)
|
|
258
|
+
result: int = args.func(args, policy)
|
|
259
|
+
return result
|
|
260
|
+
except (SkillCustomsError, FileNotFoundError, ValueError) as error:
|
|
261
|
+
_out(f"Error: {error}")
|
|
262
|
+
return 2
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
if __name__ == "__main__":
|
|
266
|
+
sys.exit(main())
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Find candidate skill repositories. This nominates: nothing is installed, and every gate still applies.
|
|
2
|
+
|
|
3
|
+
Stars can be bought, so ranking can't vouch for anything. It only decides what gets looked at first.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import math
|
|
9
|
+
import urllib.parse
|
|
10
|
+
from dataclasses import asdict, dataclass
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .errors import FetchError
|
|
15
|
+
from .fetch import GitHubClient, check_repo, is_full_sha
|
|
16
|
+
from .policy import Policy
|
|
17
|
+
|
|
18
|
+
SKILL_FILE = "SKILL.md"
|
|
19
|
+
DEFAULT_TOP = 40
|
|
20
|
+
DEFAULT_REPOS_PER_SEARCH = 5
|
|
21
|
+
DEFAULT_SKILLS_PER_REPO = 3
|
|
22
|
+
PAGE_SIZE = 100
|
|
23
|
+
MIN_AGE_DAYS = 30
|
|
24
|
+
MAX_IDLE_DAYS = 365
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Candidate:
|
|
29
|
+
repo: str
|
|
30
|
+
url: str
|
|
31
|
+
description: str
|
|
32
|
+
stars: int
|
|
33
|
+
pushed_at: str
|
|
34
|
+
license_spdx: str | None
|
|
35
|
+
license_allowed: bool
|
|
36
|
+
score: float
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> dict[str, Any]:
|
|
39
|
+
return asdict(self)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _days_since(stamp: str, now: datetime) -> float:
|
|
43
|
+
moment = datetime.fromisoformat(stamp.replace("Z", "+00:00"))
|
|
44
|
+
return (now - moment).total_seconds() / 86400
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def score_repository(item: dict[str, Any], now: datetime) -> float:
|
|
48
|
+
"""Stars count on a log scale, so a huge repository can't drown out everything else.
|
|
49
|
+
|
|
50
|
+
Recency fades over a year. A high ratio of open issues to stars lowers the score, as a
|
|
51
|
+
rough sign of unanswered problems.
|
|
52
|
+
"""
|
|
53
|
+
stars = int(item.get("stargazers_count") or 0)
|
|
54
|
+
recency = max(0.0, 1.0 - _days_since(item["pushed_at"], now) / MAX_IDLE_DAYS)
|
|
55
|
+
issues = int(item.get("open_issues_count") or 0)
|
|
56
|
+
maintenance = 1.0 - min(1.0, issues / (stars + 10))
|
|
57
|
+
return round(math.log10(stars + 1) + 2.0 * recency + maintenance, 4)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def rank_repositories(
|
|
61
|
+
items: list[dict[str, Any]], policy: Policy, *, now: datetime, top: int = DEFAULT_TOP
|
|
62
|
+
) -> list[Candidate]:
|
|
63
|
+
"""Drop archived, forked, brand-new, abandoned, and unlicensed repositories, then rank the rest."""
|
|
64
|
+
candidates = []
|
|
65
|
+
for item in items:
|
|
66
|
+
if item.get("archived") or item.get("fork"):
|
|
67
|
+
continue
|
|
68
|
+
if _days_since(item["created_at"], now) < MIN_AGE_DAYS or _days_since(item["pushed_at"], now) > MAX_IDLE_DAYS:
|
|
69
|
+
continue
|
|
70
|
+
spdx = (item.get("license") or {}).get("spdx_id")
|
|
71
|
+
if not spdx or spdx == "NOASSERTION":
|
|
72
|
+
continue
|
|
73
|
+
candidates.append(
|
|
74
|
+
Candidate(
|
|
75
|
+
repo=item["full_name"],
|
|
76
|
+
url=item["html_url"],
|
|
77
|
+
description=(item.get("description") or "")[:200],
|
|
78
|
+
stars=int(item.get("stargazers_count") or 0),
|
|
79
|
+
pushed_at=item["pushed_at"],
|
|
80
|
+
license_spdx=spdx,
|
|
81
|
+
license_allowed=spdx in policy.allowed_licenses,
|
|
82
|
+
score=score_repository(item, now),
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
candidates.sort(key=lambda c: (-c.score, c.repo))
|
|
86
|
+
return candidates[:top]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class SkillFile:
|
|
91
|
+
"""One skill found by content search. It's a nomination, like a Candidate."""
|
|
92
|
+
|
|
93
|
+
repo: str
|
|
94
|
+
path: str
|
|
95
|
+
license_spdx: str | None
|
|
96
|
+
license_allowed: bool
|
|
97
|
+
stars: int
|
|
98
|
+
score: float
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def search_skill_files(
|
|
102
|
+
query: str,
|
|
103
|
+
*,
|
|
104
|
+
client: GitHubClient | None = None,
|
|
105
|
+
policy: Policy | None = None,
|
|
106
|
+
now: datetime | None = None,
|
|
107
|
+
max_repos: int = DEFAULT_REPOS_PER_SEARCH,
|
|
108
|
+
max_per_repo: int = DEFAULT_SKILLS_PER_REPO,
|
|
109
|
+
) -> list[SkillFile]:
|
|
110
|
+
"""Find skills by what their SKILL.md says, including those deep inside large collections.
|
|
111
|
+
|
|
112
|
+
Repository search only sees a repository's own description, so it misses most skills. Code
|
|
113
|
+
search needs a token, and GitHub allows few of these per minute. Repositories still go through
|
|
114
|
+
the same filters as discover(): no archives, forks, brand-new, abandoned, or unlicensed ones.
|
|
115
|
+
"""
|
|
116
|
+
github = client or GitHubClient()
|
|
117
|
+
encoded = urllib.parse.quote(f"{query} filename:{SKILL_FILE}")
|
|
118
|
+
data = github.get_json(f"/search/code?q={encoded}&per_page={PAGE_SIZE}")
|
|
119
|
+
items = data.get("items", []) if isinstance(data, dict) else []
|
|
120
|
+
by_repo: dict[str, list[str]] = {}
|
|
121
|
+
for item in items:
|
|
122
|
+
repo = (item.get("repository") or {}).get("full_name")
|
|
123
|
+
path = str(item.get("path", ""))
|
|
124
|
+
if repo and path.split("/")[-1] == SKILL_FILE:
|
|
125
|
+
by_repo.setdefault(repo, []).append(path[: -len(SKILL_FILE)].rstrip("/"))
|
|
126
|
+
active = policy or Policy.default()
|
|
127
|
+
moment = now or datetime.now(timezone.utc)
|
|
128
|
+
found: list[SkillFile] = []
|
|
129
|
+
for repo, paths in list(by_repo.items())[:max_repos]:
|
|
130
|
+
ranked = rank_repositories([github.get_json(f"/repos/{repo}")], active, now=moment, top=1)
|
|
131
|
+
if not ranked:
|
|
132
|
+
continue
|
|
133
|
+
candidate = ranked[0]
|
|
134
|
+
found += [
|
|
135
|
+
SkillFile(repo, path, candidate.license_spdx, candidate.license_allowed, candidate.stars, candidate.score)
|
|
136
|
+
for path in sorted(paths)[:max_per_repo]
|
|
137
|
+
]
|
|
138
|
+
return found
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def list_skill_paths(repo: str, commit: str, *, client: GitHubClient | None = None) -> list[str]:
|
|
142
|
+
"""The folder of every SKILL.md at one commit, from the file listing. Nothing is downloaded or run."""
|
|
143
|
+
github = client or GitHubClient()
|
|
144
|
+
check_repo(repo)
|
|
145
|
+
if not is_full_sha(commit):
|
|
146
|
+
raise FetchError(f"'{commit}' isn't a full 40-character commit SHA.")
|
|
147
|
+
data = github.get_json(f"/repos/{repo}/git/trees/{commit}?recursive=1")
|
|
148
|
+
tree = data.get("tree", []) if isinstance(data, dict) else []
|
|
149
|
+
paths = []
|
|
150
|
+
for item in tree:
|
|
151
|
+
path = str(item.get("path", ""))
|
|
152
|
+
if item.get("type") == "blob" and (path == SKILL_FILE or path.endswith(f"/{SKILL_FILE}")):
|
|
153
|
+
paths.append(path[: -len(SKILL_FILE)].rstrip("/"))
|
|
154
|
+
return sorted(paths)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def discover(
|
|
158
|
+
query: str,
|
|
159
|
+
*,
|
|
160
|
+
client: GitHubClient | None = None,
|
|
161
|
+
policy: Policy | None = None,
|
|
162
|
+
top: int = DEFAULT_TOP,
|
|
163
|
+
now: datetime | None = None,
|
|
164
|
+
) -> list[Candidate]:
|
|
165
|
+
"""Search GitHub for one category's query. Run it on whatever schedule you like: no daemon runs here."""
|
|
166
|
+
github = client or GitHubClient()
|
|
167
|
+
encoded = urllib.parse.quote(f"{query} fork:false archived:false")
|
|
168
|
+
data = github.get_json(f"/search/repositories?q={encoded}&sort=stars&order=desc&per_page={PAGE_SIZE}")
|
|
169
|
+
items = data.get("items", []) if isinstance(data, dict) else []
|
|
170
|
+
return rank_repositories(items, policy or Policy.default(), now=now or datetime.now(timezone.utc), top=top)
|
skill_customs/errors.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Errors. Messages are safe to print, and never contain a token."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SkillCustomsError(Exception):
|
|
9
|
+
"""Base class for anything this package refuses to do."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class NotASkill(SkillCustomsError):
|
|
13
|
+
"""The folder has no SKILL.md."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FetchError(SkillCustomsError):
|
|
17
|
+
"""A download failed, or a ref or repository name was invalid."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class UnsafeArchive(SkillCustomsError):
|
|
21
|
+
"""The archive tried something a skill never needs: a link, a path escape, or too much data."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class LockfileError(SkillCustomsError):
|
|
25
|
+
"""skills.lock is missing something, or is from an unknown version."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ApprovalError(SkillCustomsError):
|
|
29
|
+
"""An approval can't be recorded."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class LicenseRejected(SkillCustomsError):
|
|
33
|
+
"""The license isn't on the allowlist, or couldn't be determined."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, message: str, verdict: Any) -> None:
|
|
36
|
+
self.verdict = verdict
|
|
37
|
+
super().__init__(message)
|