skillplus 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,40 @@
1
+ # Backend
2
+ backend/.venv/
3
+ backend/.env
4
+ backend/__pycache__/
5
+ backend/**/__pycache__/
6
+ backend/.pytest_cache/
7
+ backend/.ruff_cache/
8
+ *.pyc
9
+
10
+ # Frontend
11
+ frontend/node_modules/
12
+ frontend/.next/
13
+ frontend/out/
14
+
15
+ # Admin
16
+ skillplus-admin/node_modules/
17
+ skillplus-admin/.next/
18
+ skillplus-admin/.env
19
+
20
+ # SDK
21
+ sdk/node_modules/
22
+ sdk/dist/
23
+ python-sdk/dist/
24
+ python-sdk/.pytest_cache/
25
+ python-sdk/**/__pycache__/
26
+
27
+ # OS
28
+ .DS_Store
29
+ skills
30
+ agent-browser/
31
+
32
+ # Sensitive files (contain server passwords)
33
+ Full_Scan_Version_Management.md
34
+ skillplus_project_summary.md
35
+ .claude/skillplus-rules.md
36
+ Optimizing SkillPlus Database Performance.md
37
+
38
+ # Credential vault — plaintext secrets, NEVER commit/push. Local only.
39
+ *.local.md
40
+ docs/prod/CREDENTIALS.local.md
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SkillPlus
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,102 @@
1
+ Metadata-Version: 2.4
2
+ Name: skillplus
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for SkillPlus
5
+ Project-URL: Homepage, https://skillplus.xyz
6
+ Project-URL: Documentation, https://docs.skillplus.xyz
7
+ Author: SkillPlus
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: ai,sdk,security,skillplus
11
+ Requires-Python: >=3.10
12
+ Requires-Dist: httpx>=0.28
13
+ Provides-Extra: dev
14
+ Requires-Dist: build>=1.2; extra == 'dev'
15
+ Requires-Dist: pytest>=8; extra == 'dev'
16
+ Requires-Dist: respx>=0.22; extra == 'dev'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # SkillPlus Python SDK
20
+
21
+ Official Python SDK for the hosted SkillPlus API.
22
+
23
+ SkillPlus provides security intelligence for AI skills and agent-facing software, helping developers, teams, and platforms scan, understand, and trust AI skills before installation, approval, or integration.
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pip install skillplus
29
+ ```
30
+
31
+ ## Basic usage
32
+
33
+ ```python
34
+ from skillplus import SkillPlus
35
+
36
+ client = SkillPlus(api_key="skp_...")
37
+
38
+ result = client.query("https://github.com/owner/repo")
39
+
40
+ if result.status == "found" and result.report:
41
+ print(result.report.rating) # "safe" | "medium" | "high"
42
+ ```
43
+
44
+ ### Advanced options
45
+
46
+ For repositories containing many skills, or to queue a scan automatically when
47
+ no report exists yet:
48
+
49
+ ```python
50
+ result = client.query(
51
+ "https://github.com/owner/repo",
52
+ skill_path="skills/example", # pick one skill in a multi-skill repo
53
+ scan_if_missing=True, # queue a scan if no report exists
54
+ )
55
+ ```
56
+
57
+ The SDK talks to the official SkillPlus service at `https://skillplus.xyz`.
58
+
59
+ ## Context manager
60
+
61
+ ```python
62
+ from skillplus import SkillPlus
63
+
64
+ with SkillPlus(api_key="skp_...") as client:
65
+ result = client.scan(
66
+ "https://github.com/owner/repo",
67
+ skill_path="skills/example",
68
+ )
69
+ print(result.status)
70
+ ```
71
+
72
+ ## Methods
73
+
74
+ | Method | Use case |
75
+ |---|---|
76
+ | `query(...)` | Check whether SkillPlus already has a report for a skill. |
77
+ | `scan(...)` | Request a scan when a report is missing or when a fresh check is needed. |
78
+ | `get_report(scan_id)` | Retrieve structured report data for dashboards, registries, or automation. |
79
+ | `get_badge(scan_id)` | Fetch the badge SVG for embedding. |
80
+ | `get_badge_url(scan_id)` | Generate a badge URL for READMEs, marketplaces, or internal portals. |
81
+
82
+ ## Error handling
83
+
84
+ ```python
85
+ from skillplus import SkillPlus, SkillPlusError
86
+
87
+ client = SkillPlus(api_key="skp_...")
88
+
89
+ try:
90
+ result = client.query("https://github.com/owner/repo")
91
+ except SkillPlusError as error:
92
+ print(error.status_code)
93
+ print(error.message)
94
+ ```
95
+
96
+ ## Development
97
+
98
+ ```bash
99
+ uv sync --extra dev
100
+ uv run pytest
101
+ uv run python -m build
102
+ ```
@@ -0,0 +1,84 @@
1
+ # SkillPlus Python SDK
2
+
3
+ Official Python SDK for the hosted SkillPlus API.
4
+
5
+ SkillPlus provides security intelligence for AI skills and agent-facing software, helping developers, teams, and platforms scan, understand, and trust AI skills before installation, approval, or integration.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install skillplus
11
+ ```
12
+
13
+ ## Basic usage
14
+
15
+ ```python
16
+ from skillplus import SkillPlus
17
+
18
+ client = SkillPlus(api_key="skp_...")
19
+
20
+ result = client.query("https://github.com/owner/repo")
21
+
22
+ if result.status == "found" and result.report:
23
+ print(result.report.rating) # "safe" | "medium" | "high"
24
+ ```
25
+
26
+ ### Advanced options
27
+
28
+ For repositories containing many skills, or to queue a scan automatically when
29
+ no report exists yet:
30
+
31
+ ```python
32
+ result = client.query(
33
+ "https://github.com/owner/repo",
34
+ skill_path="skills/example", # pick one skill in a multi-skill repo
35
+ scan_if_missing=True, # queue a scan if no report exists
36
+ )
37
+ ```
38
+
39
+ The SDK talks to the official SkillPlus service at `https://skillplus.xyz`.
40
+
41
+ ## Context manager
42
+
43
+ ```python
44
+ from skillplus import SkillPlus
45
+
46
+ with SkillPlus(api_key="skp_...") as client:
47
+ result = client.scan(
48
+ "https://github.com/owner/repo",
49
+ skill_path="skills/example",
50
+ )
51
+ print(result.status)
52
+ ```
53
+
54
+ ## Methods
55
+
56
+ | Method | Use case |
57
+ |---|---|
58
+ | `query(...)` | Check whether SkillPlus already has a report for a skill. |
59
+ | `scan(...)` | Request a scan when a report is missing or when a fresh check is needed. |
60
+ | `get_report(scan_id)` | Retrieve structured report data for dashboards, registries, or automation. |
61
+ | `get_badge(scan_id)` | Fetch the badge SVG for embedding. |
62
+ | `get_badge_url(scan_id)` | Generate a badge URL for READMEs, marketplaces, or internal portals. |
63
+
64
+ ## Error handling
65
+
66
+ ```python
67
+ from skillplus import SkillPlus, SkillPlusError
68
+
69
+ client = SkillPlus(api_key="skp_...")
70
+
71
+ try:
72
+ result = client.query("https://github.com/owner/repo")
73
+ except SkillPlusError as error:
74
+ print(error.status_code)
75
+ print(error.message)
76
+ ```
77
+
78
+ ## Development
79
+
80
+ ```bash
81
+ uv sync --extra dev
82
+ uv run pytest
83
+ uv run python -m build
84
+ ```
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "skillplus"
7
+ version = "0.1.0"
8
+ description = "Official Python SDK for SkillPlus"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "SkillPlus" }]
13
+ keywords = ["skillplus", "ai", "security", "sdk"]
14
+ dependencies = ["httpx>=0.28"]
15
+
16
+ [project.urls]
17
+ Homepage = "https://skillplus.xyz"
18
+ Documentation = "https://docs.skillplus.xyz"
19
+
20
+ [project.optional-dependencies]
21
+ dev = [
22
+ "build>=1.2",
23
+ "pytest>=8",
24
+ "respx>=0.22",
25
+ ]
26
+
27
+ [tool.hatch.build.targets.wheel]
28
+ packages = ["src/skillplus"]
29
+
30
+ [tool.hatch.build.targets.sdist]
31
+ include = ["src/skillplus", "tests", "README.md", "pyproject.toml"]
32
+
33
+ [tool.pytest.ini_options]
34
+ testpaths = ["tests"]
35
+
36
+ [tool.ruff]
37
+ line-length = 100
38
+ target-version = "py310"
@@ -0,0 +1,49 @@
1
+ from .client import SkillPlus
2
+ from .errors import SkillPlusError
3
+ from .types import (
4
+ AgentRun,
5
+ AiCategoryResult,
6
+ AiFalsePositive,
7
+ AiReport,
8
+ AiReportStatus,
9
+ IssueCategory,
10
+ PlatformListing,
11
+ QueryResult,
12
+ QueryStatus,
13
+ Rating,
14
+ ScanIssue,
15
+ ScanReport,
16
+ ScanResult,
17
+ ScanStatus,
18
+ ScanSummary,
19
+ Severity,
20
+ SeverityNormalized,
21
+ SupplyChainHit,
22
+ SupplyChainReport,
23
+ Verdict,
24
+ )
25
+
26
+ __all__ = [
27
+ "AgentRun",
28
+ "AiCategoryResult",
29
+ "AiFalsePositive",
30
+ "AiReport",
31
+ "AiReportStatus",
32
+ "IssueCategory",
33
+ "PlatformListing",
34
+ "QueryResult",
35
+ "QueryStatus",
36
+ "Rating",
37
+ "ScanIssue",
38
+ "ScanReport",
39
+ "ScanResult",
40
+ "ScanStatus",
41
+ "ScanSummary",
42
+ "Severity",
43
+ "SeverityNormalized",
44
+ "SupplyChainHit",
45
+ "SupplyChainReport",
46
+ "SkillPlus",
47
+ "SkillPlusError",
48
+ "Verdict",
49
+ ]
@@ -0,0 +1,378 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Any
5
+
6
+ import httpx
7
+
8
+ from .errors import SkillPlusError
9
+ from .types import (
10
+ AgentRun,
11
+ AiCategoryResult,
12
+ AiFalsePositive,
13
+ AiReport,
14
+ PlatformListing,
15
+ QueryResult,
16
+ ScanIssue,
17
+ ScanReport,
18
+ ScanResult,
19
+ ScanSummary,
20
+ SupplyChainHit,
21
+ SupplyChainReport,
22
+ )
23
+
24
+ DEFAULT_BASE_URL = "https://skillplus.xyz"
25
+ DEFAULT_TIMEOUT = 30.0
26
+ DEFAULT_MAX_RETRIES = 2
27
+ _RETRY_STATUSES = frozenset({429, 502, 503})
28
+
29
+
30
+ class SkillPlus:
31
+ """Client for the hosted SkillPlus API."""
32
+
33
+ def __init__(
34
+ self,
35
+ api_key: str,
36
+ *,
37
+ base_url: str = DEFAULT_BASE_URL,
38
+ timeout: float = DEFAULT_TIMEOUT,
39
+ max_retries: int = DEFAULT_MAX_RETRIES,
40
+ ) -> None:
41
+ if not api_key:
42
+ raise ValueError("api_key is required")
43
+
44
+ self._api_key = api_key
45
+ self._base_url = base_url.rstrip("/")
46
+ self._max_retries = max(0, max_retries)
47
+ self._client = httpx.Client(
48
+ base_url=self._base_url,
49
+ timeout=timeout,
50
+ headers={
51
+ "Authorization": f"Bearer {api_key}",
52
+ "Content-Type": "application/json",
53
+ "User-Agent": "skillplus-python/0.1.0",
54
+ },
55
+ )
56
+
57
+ def __enter__(self) -> SkillPlus:
58
+ return self
59
+
60
+ def __exit__(self, *exc_info: object) -> None:
61
+ self.close()
62
+
63
+ def close(self) -> None:
64
+ """Close the underlying HTTP client."""
65
+
66
+ self._client.close()
67
+
68
+ def query(
69
+ self,
70
+ repo_url: str,
71
+ *,
72
+ skill_path: str | None = None,
73
+ scan_if_missing: bool = False,
74
+ preferred_provider: str | None = None,
75
+ ) -> QueryResult:
76
+ """Query an existing report, optionally queueing a scan if no report exists."""
77
+
78
+ payload = self._request_json(
79
+ "POST",
80
+ "/api/sdk/query",
81
+ json={
82
+ "repo_url": repo_url,
83
+ "skill_path": skill_path,
84
+ "scan_if_missing": scan_if_missing,
85
+ "preferred_provider": preferred_provider,
86
+ },
87
+ )
88
+ return _parse_query_result(payload)
89
+
90
+ def scan(
91
+ self,
92
+ repo_url: str,
93
+ *,
94
+ skill_path: str | None = None,
95
+ preferred_provider: str | None = None,
96
+ force: bool = False,
97
+ ) -> ScanResult:
98
+ """Request a scan for a skill."""
99
+
100
+ payload = self._request_json(
101
+ "POST",
102
+ "/api/sdk/scan",
103
+ json={
104
+ "repo_url": repo_url,
105
+ "skill_path": skill_path,
106
+ "preferred_provider": preferred_provider,
107
+ "force": force,
108
+ },
109
+ )
110
+ return _parse_scan_result(payload)
111
+
112
+ def wait_for_report(
113
+ self,
114
+ repo_url: str,
115
+ *,
116
+ skill_path: str | None = None,
117
+ preferred_provider: str | None = None,
118
+ interval: float = 5.0,
119
+ timeout: float = 600.0,
120
+ ) -> ScanReport:
121
+ """Query-and-wait: poll until a completed report exists (scanning if
122
+ missing). This is the reliable path after a ``queued`` result, which
123
+ may carry no scan_id to poll."""
124
+
125
+ deadline = time.monotonic() + timeout
126
+ while True:
127
+ result = self.query(
128
+ repo_url,
129
+ skill_path=skill_path,
130
+ scan_if_missing=True,
131
+ preferred_provider=preferred_provider,
132
+ )
133
+ if result.status == "found" and result.report and result.report.status == "completed":
134
+ return result.report
135
+ if result.status in ("failed", "not_found"):
136
+ raise SkillPlusError(f"Scan {result.status}: {result.message}", detail=result.status)
137
+ if time.monotonic() + interval > deadline:
138
+ raise SkillPlusError(
139
+ f"Timed out waiting for report (last status: {result.status})",
140
+ detail=result.status,
141
+ )
142
+ time.sleep(interval)
143
+
144
+ def get_report(self, scan_id: str) -> ScanReport:
145
+ """Retrieve structured report data by scan ID."""
146
+
147
+ payload = self._request_json("GET", f"/api/report/{scan_id}")
148
+ return _parse_report(payload)
149
+
150
+ def get_badge(self, scan_id: str) -> str:
151
+ """Fetch the badge SVG for a report."""
152
+
153
+ return self._request_text("GET", f"/api/report/{scan_id}/badge.svg")
154
+
155
+ def get_badge_url(self, scan_id: str) -> str:
156
+ """Return the public badge URL for a report."""
157
+
158
+ return f"{self._base_url}/api/report/{scan_id}/badge.svg"
159
+
160
+ def _request_json(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
161
+ response = self._request(method, path, **kwargs)
162
+ try:
163
+ payload = response.json()
164
+ except ValueError as exc:
165
+ raise SkillPlusError("SkillPlus returned invalid JSON", status_code=response.status_code) from exc
166
+
167
+ if not isinstance(payload, dict):
168
+ raise SkillPlusError(
169
+ "SkillPlus returned an unexpected response",
170
+ status_code=response.status_code,
171
+ detail=payload,
172
+ )
173
+ return payload
174
+
175
+ def _request_text(self, method: str, path: str, **kwargs: Any) -> str:
176
+ return self._request(method, path, **kwargs).text
177
+
178
+ @staticmethod
179
+ def _retry_delay(response: httpx.Response | None, attempt: int) -> float:
180
+ if response is not None:
181
+ retry_after = response.headers.get("Retry-After")
182
+ if retry_after:
183
+ try:
184
+ return min(max(float(retry_after), 0.0), 60.0)
185
+ except ValueError:
186
+ pass
187
+ return min(2.0**attempt, 10.0)
188
+
189
+ def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
190
+ response: httpx.Response | None = None
191
+ for attempt in range(self._max_retries + 1):
192
+ try:
193
+ response = self._client.request(method, path, **kwargs)
194
+ except httpx.RequestError as exc:
195
+ if attempt < self._max_retries:
196
+ time.sleep(self._retry_delay(None, attempt))
197
+ continue
198
+ raise SkillPlusError(str(exc), detail=exc) from exc
199
+ if response.status_code in _RETRY_STATUSES and attempt < self._max_retries:
200
+ time.sleep(self._retry_delay(response, attempt))
201
+ continue
202
+ break
203
+ assert response is not None
204
+
205
+ if response.is_error:
206
+ detail: object | None = None
207
+ message = response.reason_phrase
208
+ try:
209
+ detail = response.json()
210
+ if isinstance(detail, dict):
211
+ message = str(detail.get("detail") or detail.get("message") or message)
212
+ except ValueError:
213
+ detail = response.text or None
214
+ if response.text:
215
+ message = response.text
216
+
217
+ raise SkillPlusError(message, status_code=response.status_code, detail=detail)
218
+
219
+ return response
220
+
221
+
222
+ def _parse_query_result(payload: dict[str, Any]) -> QueryResult:
223
+ return QueryResult(
224
+ status=payload["status"],
225
+ message=payload["message"],
226
+ scan_id=payload.get("scan_id"),
227
+ poll_url=payload.get("poll_url"),
228
+ report=_parse_report(payload["report"]) if payload.get("report") else None,
229
+ )
230
+
231
+
232
+ def _parse_scan_result(payload: dict[str, Any]) -> ScanResult:
233
+ return ScanResult(
234
+ status=payload["status"],
235
+ message=payload["message"],
236
+ scan_id=payload.get("scan_id"),
237
+ poll_url=payload.get("poll_url"),
238
+ report=_parse_report(payload["report"]) if payload.get("report") else None,
239
+ )
240
+
241
+
242
+ def _parse_report(payload: dict[str, Any]) -> ScanReport:
243
+ return ScanReport(
244
+ scan_id=payload["scan_id"],
245
+ skill_name=payload["skill_name"],
246
+ source=payload["source"],
247
+ skill_path=payload["skill_path"],
248
+ tree_sha=payload["tree_sha"],
249
+ rule_version=payload["rule_version"],
250
+ valid_skill=payload["valid_skill"],
251
+ rating=payload["rating"],
252
+ verdict=payload.get("verdict")
253
+ or {"low": "safe", "critical": "high"}.get(payload["rating"], payload["rating"]),
254
+ scanned_at=payload["scanned_at"],
255
+ cached=payload["cached"],
256
+ scan_duration_ms=payload["scan_duration_ms"],
257
+ summary=_parse_summary(payload["summary"]),
258
+ issues=[_parse_issue(issue) for issue in payload.get("issues", [])],
259
+ report_url=payload["report_url"],
260
+ badge_url=payload["badge_url"],
261
+ blacklisted=bool(payload.get("blacklisted")),
262
+ whitelisted=bool(payload.get("whitelisted")),
263
+ status=payload["status"],
264
+ ai_report=_parse_ai_report(payload["ai_report"]) if payload.get("ai_report") else None,
265
+ external_links=payload.get("external_links") or {},
266
+ platform_listings=[
267
+ PlatformListing(
268
+ platform=listing["platform"],
269
+ url=listing["url"],
270
+ installs=listing["installs"],
271
+ )
272
+ for listing in payload.get("platform_listings", [])
273
+ ],
274
+ supply_chain=_parse_supply_chain(payload.get("supply_chain")),
275
+ )
276
+
277
+
278
+ def _parse_supply_chain(payload: dict[str, Any] | None) -> SupplyChainReport | None:
279
+ if not payload:
280
+ return None
281
+ return SupplyChainReport(
282
+ dependency_count=payload.get("dependency_count") or 0,
283
+ endpoint_count=payload.get("endpoint_count") or 0,
284
+ blacklist_hits=[
285
+ SupplyChainHit(
286
+ kind=hit["kind"],
287
+ ecosystem=hit.get("ecosystem"),
288
+ name=hit["name"],
289
+ version_spec=hit.get("version_spec") or {},
290
+ reason=hit["reason"],
291
+ severity=hit["severity"],
292
+ reference_url=hit.get("reference_url"),
293
+ match_strength=hit["match_strength"],
294
+ matched_version=hit.get("matched_version"),
295
+ )
296
+ for hit in payload.get("blacklist_hits", [])
297
+ ],
298
+ )
299
+
300
+
301
+ def _parse_summary(payload: dict[str, Any]) -> ScanSummary:
302
+ return ScanSummary(
303
+ total_issues=payload["total_issues"],
304
+ danger=payload["danger"],
305
+ warning=payload["warning"],
306
+ info=payload["info"],
307
+ files_scanned=payload["files_scanned"],
308
+ by_category=payload.get("by_category") or {},
309
+ )
310
+
311
+
312
+ def _parse_issue(payload: dict[str, Any]) -> ScanIssue:
313
+ return ScanIssue(
314
+ rule_id=payload["rule_id"],
315
+ severity=payload["severity"],
316
+ severity_normalized=payload.get("severity_normalized")
317
+ or {"danger": "high", "warning": "medium", "info": "low"}.get(
318
+ payload["severity"], payload["severity"]
319
+ ),
320
+ category=payload["category"],
321
+ message=payload["message"],
322
+ file=payload.get("file"),
323
+ line=payload.get("line"),
324
+ snippet=payload.get("snippet"),
325
+ domain=payload.get("domain"),
326
+ )
327
+
328
+
329
+ def _parse_ai_report(payload: dict[str, Any]) -> AiReport:
330
+ return AiReport(
331
+ status=payload["status"],
332
+ risk_level=payload.get("risk_level"),
333
+ refined_rating=payload.get("refined_rating"),
334
+ false_positives=[
335
+ AiFalsePositive(
336
+ rule_id=false_positive["rule_id"],
337
+ file=false_positive["file"],
338
+ reason=false_positive["reason"],
339
+ )
340
+ for false_positive in payload.get("false_positives", [])
341
+ ],
342
+ analysis=[_parse_ai_category(item) for item in payload.get("analysis", [])],
343
+ summary=payload.get("summary"),
344
+ categories_checked=[
345
+ _parse_ai_category(item) for item in payload.get("categories_checked", [])
346
+ ],
347
+ tokens_used=payload.get("tokens_used"),
348
+ duration_ms=payload.get("duration_ms"),
349
+ error_message=payload.get("error_message"),
350
+ agents=(
351
+ [
352
+ AgentRun(
353
+ agent=run["agent"],
354
+ status=run["status"],
355
+ findings=run.get("findings") or [],
356
+ summary=run.get("summary"),
357
+ tokens_used=run.get("tokens_used"),
358
+ duration_ms=run.get("duration_ms"),
359
+ error_message=run.get("error_message"),
360
+ )
361
+ for run in payload["agents"]
362
+ ]
363
+ if payload.get("agents") is not None
364
+ else None
365
+ ),
366
+ )
367
+
368
+
369
+ def _parse_ai_category(payload: dict[str, Any]) -> AiCategoryResult:
370
+ return AiCategoryResult(
371
+ category=payload["category"],
372
+ status=payload["status"],
373
+ severity=payload["severity"],
374
+ title=payload["title"],
375
+ description=payload["description"],
376
+ evidence=payload.get("evidence") or [],
377
+ confidence=payload.get("confidence") or 0.0,
378
+ )
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class SkillPlusError(Exception):
5
+ """Error raised by the SkillPlus SDK."""
6
+
7
+ def __init__(
8
+ self,
9
+ message: str,
10
+ *,
11
+ status_code: int | None = None,
12
+ detail: object | None = None,
13
+ ) -> None:
14
+ super().__init__(message)
15
+ self.message = message
16
+ self.status_code = status_code
17
+ self.detail = detail
File without changes
@@ -0,0 +1,176 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Literal, TypeAlias
5
+
6
+ Rating: TypeAlias = Literal["safe", "low", "medium", "high", "unknown"]
7
+ # Rating system v3: three-tier verdict (low→safe, critical→high). Prefer over Rating.
8
+ Verdict: TypeAlias = Literal["safe", "medium", "high", "unknown"]
9
+ # Raw rule-issue severity. v3 scans emit high/medium/low; reports scanned
10
+ # before v3 may still carry danger/warning/info. Prefer SeverityNormalized.
11
+ Severity: TypeAlias = Literal[
12
+ "high", "medium", "low", "critical", "danger", "warning", "info", "safe"
13
+ ]
14
+ # Rating system v3: unified finding severity. Prefer over Severity.
15
+ SeverityNormalized: TypeAlias = Literal["high", "medium", "low", "safe"]
16
+ IssueCategory: TypeAlias = Literal[
17
+ "url",
18
+ "code",
19
+ "prompt",
20
+ "structure",
21
+ "persistence",
22
+ "credential",
23
+ "supply_chain",
24
+ "propagation",
25
+ ]
26
+ QueryStatus: TypeAlias = Literal["found", "queued", "running", "not_found", "failed"]
27
+ ScanStatus: TypeAlias = Literal["found", "queued", "running", "failed"]
28
+ AiReportStatus: TypeAlias = Literal["pending", "processing", "completed", "failed"]
29
+
30
+
31
+ @dataclass(slots=True)
32
+ class ScanIssue:
33
+ rule_id: str
34
+ severity: Severity # deprecated: prefer severity_normalized
35
+ severity_normalized: SeverityNormalized
36
+ category: IssueCategory
37
+ message: str
38
+ file: str | None = None
39
+ line: int | None = None
40
+ snippet: str | None = None
41
+ domain: str | None = None
42
+
43
+
44
+ @dataclass(slots=True)
45
+ class ScanSummary:
46
+ total_issues: int
47
+ danger: int
48
+ warning: int
49
+ info: int
50
+ files_scanned: int
51
+ by_category: dict[str, int] = field(default_factory=dict)
52
+
53
+
54
+ @dataclass(slots=True)
55
+ class AiFalsePositive:
56
+ rule_id: str
57
+ file: str
58
+ reason: str
59
+
60
+
61
+ @dataclass(slots=True)
62
+ class AiCategoryResult:
63
+ category: str
64
+ status: str
65
+ severity: str
66
+ title: str
67
+ description: str
68
+ evidence: list[str] = field(default_factory=list)
69
+ confidence: float = 0.0
70
+
71
+
72
+ @dataclass(slots=True)
73
+ class AgentRun:
74
+ """One agent's run in the v2 multi-agent pipeline (present on get_report)."""
75
+
76
+ agent: str
77
+ status: str
78
+ findings: list[dict] = field(default_factory=list)
79
+ summary: str | None = None
80
+ tokens_used: int | None = None
81
+ duration_ms: int | None = None
82
+ error_message: str | None = None
83
+
84
+
85
+ @dataclass(slots=True)
86
+ class AiReport:
87
+ status: AiReportStatus
88
+ risk_level: str | None
89
+ refined_rating: Rating | None
90
+ false_positives: list[AiFalsePositive] = field(default_factory=list)
91
+ analysis: list[AiCategoryResult] = field(default_factory=list)
92
+ summary: str | None = None
93
+ categories_checked: list[AiCategoryResult] = field(default_factory=list)
94
+ tokens_used: int | None = None
95
+ duration_ms: int | None = None
96
+ error_message: str | None = None
97
+ # Per-agent detail (multi-agent pipeline); None when not exposed.
98
+ agents: list[AgentRun] | None = None
99
+
100
+
101
+ @dataclass(slots=True)
102
+ class SupplyChainHit:
103
+ """One supply-chain blacklist hit (a dependency/domain known to be poisoned)."""
104
+
105
+ kind: str
106
+ ecosystem: str | None
107
+ name: str
108
+ version_spec: dict
109
+ reason: str
110
+ severity: str
111
+ reference_url: str | None
112
+ match_strength: str
113
+ matched_version: str | None = None
114
+
115
+
116
+ @dataclass(slots=True)
117
+ class SupplyChainReport:
118
+ """Supply-chain section of a report. Any blacklist hit floors the verdict
119
+ to high unless the skill is admin-whitelisted."""
120
+
121
+ dependency_count: int = 0
122
+ endpoint_count: int = 0
123
+ blacklist_hits: list[SupplyChainHit] = field(default_factory=list)
124
+
125
+
126
+ @dataclass(slots=True)
127
+ class PlatformListing:
128
+ platform: str
129
+ url: str
130
+ installs: int
131
+
132
+
133
+ @dataclass(slots=True)
134
+ class ScanReport:
135
+ scan_id: str
136
+ skill_name: str
137
+ source: str
138
+ skill_path: str
139
+ tree_sha: str
140
+ rule_version: str
141
+ valid_skill: bool
142
+ rating: Rating # deprecated: prefer verdict
143
+ verdict: Verdict
144
+ scanned_at: str
145
+ cached: bool
146
+ scan_duration_ms: int
147
+ summary: ScanSummary
148
+ issues: list[ScanIssue]
149
+ report_url: str
150
+ badge_url: str
151
+ blacklisted: bool
152
+ whitelisted: bool
153
+ status: str
154
+ ai_report: AiReport | None = None
155
+ external_links: dict[str, str] = field(default_factory=dict)
156
+ platform_listings: list[PlatformListing] = field(default_factory=list)
157
+ # Supply-chain snapshot; None when the section is unavailable.
158
+ supply_chain: SupplyChainReport | None = None
159
+
160
+
161
+ @dataclass(slots=True)
162
+ class QueryResult:
163
+ status: QueryStatus
164
+ message: str
165
+ scan_id: str | None = None
166
+ poll_url: str | None = None
167
+ report: ScanReport | None = None
168
+
169
+
170
+ @dataclass(slots=True)
171
+ class ScanResult:
172
+ status: ScanStatus
173
+ message: str
174
+ scan_id: str | None = None
175
+ poll_url: str | None = None
176
+ report: ScanReport | None = None
@@ -0,0 +1,223 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ import pytest
6
+ import respx
7
+ from httpx import Response
8
+
9
+ from skillplus import SkillPlus, SkillPlusError
10
+
11
+
12
+ REPORT_PAYLOAD = {
13
+ "scan_id": "scan_123",
14
+ "skill_name": "example",
15
+ "source": "github:owner/repo",
16
+ "skill_path": "skills/example",
17
+ "tree_sha": "abc123",
18
+ "rule_version": "rules-v1",
19
+ "valid_skill": True,
20
+ "rating": "safe",
21
+ "scanned_at": "2026-06-10T00:00:00Z",
22
+ "cached": True,
23
+ "scan_duration_ms": 1200,
24
+ "summary": {
25
+ "total_issues": 1,
26
+ "danger": 0,
27
+ "warning": 1,
28
+ "info": 0,
29
+ "files_scanned": 4,
30
+ "by_category": {"url": 1},
31
+ },
32
+ "issues": [
33
+ {
34
+ "rule_id": "external-url",
35
+ "severity": "warning",
36
+ "category": "url",
37
+ "message": "External URL found",
38
+ "file": "SKILL.md",
39
+ "line": 12,
40
+ "snippet": "https://example.com",
41
+ "domain": "example.com",
42
+ }
43
+ ],
44
+ "report_url": "https://skillplus.xyz/report/scan_123",
45
+ "badge_url": "https://skillplus.xyz/api/report/scan_123/badge.svg",
46
+ "blacklisted": False,
47
+ "whitelisted": False,
48
+ "status": "completed",
49
+ "ai_report": {
50
+ "status": "completed",
51
+ "risk_level": "low",
52
+ "refined_rating": "safe",
53
+ "false_positives": [
54
+ {"rule_id": "external-url", "file": "SKILL.md", "reason": "Documentation link"}
55
+ ],
56
+ "analysis": [
57
+ {
58
+ "category": "Command Execution",
59
+ "status": "pass",
60
+ "severity": "safe",
61
+ "title": "No command execution",
62
+ "description": "No risky command execution was found.",
63
+ "evidence": ["No shell usage"],
64
+ "confidence": 0.98,
65
+ }
66
+ ],
67
+ "summary": "No meaningful risk found.",
68
+ "categories_checked": [],
69
+ "tokens_used": 500,
70
+ "duration_ms": 800,
71
+ "error_message": None,
72
+ },
73
+ "external_links": {"website": "https://example.com"},
74
+ "platform_listings": [
75
+ {"platform": "skills.sh", "url": "https://skills.sh/owner/repo/example", "installs": 10}
76
+ ],
77
+ }
78
+
79
+
80
+ @respx.mock
81
+ def test_query_sends_auth_and_request_body() -> None:
82
+ route = respx.post("https://skillplus.xyz/api/sdk/query").mock(
83
+ return_value=Response(
84
+ 200,
85
+ json={
86
+ "status": "found",
87
+ "message": "Report found",
88
+ "scan_id": "scan_123",
89
+ "poll_url": "/api/scan/status/scan_123",
90
+ "report": REPORT_PAYLOAD,
91
+ },
92
+ )
93
+ )
94
+
95
+ with SkillPlus(api_key="skp_test") as client:
96
+ result = client.query(
97
+ "https://github.com/owner/repo",
98
+ skill_path="skills/example",
99
+ scan_if_missing=True,
100
+ preferred_provider="github",
101
+ )
102
+
103
+ request = route.calls.last.request
104
+ assert request.headers["Authorization"] == "Bearer skp_test"
105
+ assert request.headers["Content-Type"] == "application/json"
106
+ assert json.loads(request.read()) == {
107
+ "repo_url": "https://github.com/owner/repo",
108
+ "skill_path": "skills/example",
109
+ "scan_if_missing": True,
110
+ "preferred_provider": "github",
111
+ }
112
+ assert result.status == "found"
113
+ assert result.scan_id == "scan_123"
114
+ assert result.report is not None
115
+ assert result.report.rating == "safe"
116
+ assert result.report.summary.warning == 1
117
+ assert result.report.issues[0].rule_id == "external-url"
118
+ assert result.report.ai_report is not None
119
+ assert result.report.ai_report.false_positives[0].reason == "Documentation link"
120
+
121
+
122
+ @respx.mock
123
+ def test_query_without_report_returns_status_envelope() -> None:
124
+ respx.post("https://skillplus.xyz/api/sdk/query").mock(
125
+ return_value=Response(
126
+ 200,
127
+ json={
128
+ "status": "queued",
129
+ "message": "Scan queued",
130
+ "scan_id": "scan_queued",
131
+ "poll_url": "/api/scan/status/scan_queued",
132
+ "report": None,
133
+ },
134
+ )
135
+ )
136
+
137
+ client = SkillPlus(api_key="skp_test")
138
+ try:
139
+ result = client.query("https://github.com/owner/repo", scan_if_missing=True)
140
+ finally:
141
+ client.close()
142
+
143
+ assert result.status == "queued"
144
+ assert result.scan_id == "scan_queued"
145
+ assert result.report is None
146
+
147
+
148
+ @respx.mock
149
+ def test_scan_sends_force_flag() -> None:
150
+ route = respx.post("https://skillplus.xyz/api/sdk/scan").mock(
151
+ return_value=Response(
152
+ 200,
153
+ json={
154
+ "status": "running",
155
+ "message": "Scan already running",
156
+ "scan_id": "scan_running",
157
+ "poll_url": "/api/scan/status/scan_running",
158
+ "report": None,
159
+ },
160
+ )
161
+ )
162
+
163
+ with SkillPlus(api_key="skp_test") as client:
164
+ result = client.scan("https://github.com/owner/repo", skill_path="skills/example", force=True)
165
+
166
+ assert json.loads(route.calls.last.request.read()) == {
167
+ "repo_url": "https://github.com/owner/repo",
168
+ "skill_path": "skills/example",
169
+ "preferred_provider": None,
170
+ "force": True,
171
+ }
172
+ assert result.status == "running"
173
+ assert result.report is None
174
+
175
+
176
+ @respx.mock
177
+ def test_get_report_parses_report() -> None:
178
+ respx.get("https://skillplus.xyz/api/report/scan_123").mock(
179
+ return_value=Response(200, json=REPORT_PAYLOAD)
180
+ )
181
+
182
+ with SkillPlus(api_key="skp_test") as client:
183
+ report = client.get_report("scan_123")
184
+
185
+ assert report.scan_id == "scan_123"
186
+ assert report.platform_listings[0].platform == "skills.sh"
187
+
188
+
189
+ @respx.mock
190
+ def test_get_badge_returns_svg_text() -> None:
191
+ respx.get("https://skillplus.xyz/api/report/scan_123/badge.svg").mock(
192
+ return_value=Response(200, text="<svg></svg>")
193
+ )
194
+
195
+ with SkillPlus(api_key="skp_test") as client:
196
+ badge = client.get_badge("scan_123")
197
+
198
+ assert badge == "<svg></svg>"
199
+
200
+
201
+ def test_get_badge_url_uses_hosted_service() -> None:
202
+ client = SkillPlus(api_key="skp_test")
203
+ try:
204
+ assert (
205
+ client.get_badge_url("scan_123")
206
+ == "https://skillplus.xyz/api/report/scan_123/badge.svg"
207
+ )
208
+ finally:
209
+ client.close()
210
+
211
+
212
+ @respx.mock
213
+ def test_non_ok_response_raises_skillplus_error() -> None:
214
+ respx.post("https://skillplus.xyz/api/sdk/query").mock(
215
+ return_value=Response(401, json={"detail": "Invalid API key"})
216
+ )
217
+
218
+ with SkillPlus(api_key="bad_key") as client:
219
+ with pytest.raises(SkillPlusError) as exc_info:
220
+ client.query("https://github.com/owner/repo")
221
+
222
+ assert exc_info.value.status_code == 401
223
+ assert exc_info.value.message == "Invalid API key"
@@ -0,0 +1,185 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+
5
+ import pytest
6
+ import respx
7
+ from httpx import Response
8
+
9
+ from skillplus import SkillPlus, SkillPlusError
10
+
11
+ from test_client import REPORT_PAYLOAD
12
+
13
+
14
+ def _report(**overrides):
15
+ payload = copy.deepcopy(REPORT_PAYLOAD)
16
+ payload.update(overrides)
17
+ return payload
18
+
19
+
20
+ SUPPLY_CHAIN = {
21
+ "dependency_count": 12,
22
+ "endpoint_count": 3,
23
+ "blacklist_hits": [
24
+ {
25
+ "kind": "package",
26
+ "ecosystem": "pypi",
27
+ "name": "litellm",
28
+ "version_spec": {"exact": ["1.82.7", "1.82.8"]},
29
+ "reason": "known malicious release",
30
+ "severity": "critical",
31
+ "reference_url": "https://example.com/advisory",
32
+ "match_strength": "exact",
33
+ "matched_version": "1.82.7",
34
+ }
35
+ ],
36
+ }
37
+
38
+
39
+ @respx.mock
40
+ def test_supply_chain_parsed() -> None:
41
+ respx.get("https://skillplus.xyz/api/report/scan_123").mock(
42
+ return_value=Response(200, json=_report(supply_chain=SUPPLY_CHAIN))
43
+ )
44
+ with SkillPlus(api_key="skp_test") as client:
45
+ report = client.get_report("scan_123")
46
+
47
+ assert report.supply_chain is not None
48
+ assert report.supply_chain.dependency_count == 12
49
+ assert report.supply_chain.endpoint_count == 3
50
+ hit = report.supply_chain.blacklist_hits[0]
51
+ assert hit.name == "litellm"
52
+ assert hit.ecosystem == "pypi"
53
+ assert hit.matched_version == "1.82.7"
54
+ assert hit.version_spec == {"exact": ["1.82.7", "1.82.8"]}
55
+
56
+
57
+ @respx.mock
58
+ def test_supply_chain_absent_is_none() -> None:
59
+ respx.get("https://skillplus.xyz/api/report/scan_123").mock(
60
+ return_value=Response(200, json=_report())
61
+ )
62
+ with SkillPlus(api_key="skp_test") as client:
63
+ report = client.get_report("scan_123")
64
+ assert report.supply_chain is None
65
+
66
+
67
+ @respx.mock
68
+ def test_agent_runs_parsed() -> None:
69
+ payload = _report()
70
+ payload["ai_report"]["agents"] = [
71
+ {
72
+ "agent": "supply_chain",
73
+ "status": "completed",
74
+ "findings": [{"category": "SUPPLY_CHAIN", "severity": "high"}],
75
+ "summary": "one poisoned dependency",
76
+ "tokens_used": 1234,
77
+ "duration_ms": 4000,
78
+ "error_message": None,
79
+ }
80
+ ]
81
+ respx.get("https://skillplus.xyz/api/report/scan_123").mock(
82
+ return_value=Response(200, json=payload)
83
+ )
84
+ with SkillPlus(api_key="skp_test") as client:
85
+ report = client.get_report("scan_123")
86
+
87
+ assert report.ai_report is not None
88
+ assert report.ai_report.agents is not None
89
+ assert report.ai_report.agents[0].agent == "supply_chain"
90
+ assert report.ai_report.agents[0].findings[0]["severity"] == "high"
91
+
92
+
93
+ @respx.mock
94
+ def test_v3_severity_passthrough() -> None:
95
+ payload = _report()
96
+ payload["issues"][0]["severity"] = "high"
97
+ respx.get("https://skillplus.xyz/api/report/scan_123").mock(
98
+ return_value=Response(200, json=payload)
99
+ )
100
+ with SkillPlus(api_key="skp_test") as client:
101
+ report = client.get_report("scan_123")
102
+ assert report.issues[0].severity == "high"
103
+ assert report.issues[0].severity_normalized == "high"
104
+
105
+
106
+ @respx.mock
107
+ def test_base_url_override() -> None:
108
+ respx.get("https://staging.example.com/api/report/scan_123").mock(
109
+ return_value=Response(200, json=_report())
110
+ )
111
+ with SkillPlus(api_key="skp_test", base_url="https://staging.example.com/") as client:
112
+ report = client.get_report("scan_123")
113
+ assert client.get_badge_url("scan_123") == (
114
+ "https://staging.example.com/api/report/scan_123/badge.svg"
115
+ )
116
+ assert report.scan_id == "scan_123"
117
+
118
+
119
+ @respx.mock
120
+ def test_retry_on_429_with_retry_after() -> None:
121
+ route = respx.get("https://skillplus.xyz/api/report/scan_123")
122
+ route.side_effect = [
123
+ Response(429, headers={"Retry-After": "0"}, json={"detail": "Rate limit exceeded"}),
124
+ Response(200, json=_report()),
125
+ ]
126
+ with SkillPlus(api_key="skp_test") as client:
127
+ report = client.get_report("scan_123")
128
+ assert report.scan_id == "scan_123"
129
+ assert route.call_count == 2
130
+
131
+
132
+ @respx.mock
133
+ def test_no_retry_when_disabled() -> None:
134
+ respx.get("https://skillplus.xyz/api/report/scan_123").mock(
135
+ return_value=Response(429, headers={"Retry-After": "0"}, json={"detail": "Rate limit exceeded"})
136
+ )
137
+ with SkillPlus(api_key="skp_test", max_retries=0) as client:
138
+ with pytest.raises(SkillPlusError) as excinfo:
139
+ client.get_report("scan_123")
140
+ assert excinfo.value.status_code == 429
141
+
142
+
143
+ @respx.mock
144
+ def test_wait_for_report_polls_until_completed() -> None:
145
+ queued = {"status": "queued", "message": "Scan queued", "scan_id": None, "poll_url": None}
146
+ found = {
147
+ "status": "found",
148
+ "message": "Report found",
149
+ "scan_id": "scan_123",
150
+ "poll_url": "/api/scan/status/scan_123",
151
+ "report": _report(),
152
+ }
153
+ route = respx.post("https://skillplus.xyz/api/sdk/query")
154
+ route.side_effect = [Response(200, json=queued), Response(200, json=found)]
155
+
156
+ with SkillPlus(api_key="skp_test") as client:
157
+ report = client.wait_for_report(
158
+ "https://github.com/owner/repo", interval=0.01, timeout=5.0
159
+ )
160
+
161
+ assert report.scan_id == "scan_123"
162
+ assert route.call_count == 2
163
+
164
+
165
+ @respx.mock
166
+ def test_wait_for_report_times_out() -> None:
167
+ queued = {"status": "queued", "message": "Scan queued", "scan_id": None, "poll_url": None}
168
+ respx.post("https://skillplus.xyz/api/sdk/query").mock(
169
+ return_value=Response(200, json=queued)
170
+ )
171
+ with SkillPlus(api_key="skp_test") as client:
172
+ with pytest.raises(SkillPlusError) as excinfo:
173
+ client.wait_for_report("https://github.com/owner/repo", interval=0.01, timeout=0.02)
174
+ assert "Timed out" in str(excinfo.value)
175
+
176
+
177
+ @respx.mock
178
+ def test_verdict_fallback_from_legacy_rating() -> None:
179
+ respx.get("https://skillplus.xyz/api/report/scan_123").mock(
180
+ return_value=Response(200, json=_report(rating="low"))
181
+ )
182
+ with SkillPlus(api_key="skp_test") as client:
183
+ report = client.get_report("scan_123")
184
+ assert report.rating == "low"
185
+ assert report.verdict == "safe"