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.
@@ -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)
@@ -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)