skillplus 0.1.1__tar.gz → 0.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skillplus
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Official Python SDK for SkillPlus
5
5
  Project-URL: Homepage, https://skillplus.xyz
6
6
  Project-URL: Documentation, https://docs.skillplus.xyz
@@ -30,7 +30,9 @@ pip install skillplus
30
30
 
31
31
  Requires Python 3.10+.
32
32
 
33
- ## Basic usage
33
+ ## Quick start
34
+
35
+ `query` gives a binary answer: is there a report, or not.
34
36
 
35
37
  ```python
36
38
  from skillplus import SkillPlus
@@ -40,43 +42,41 @@ client = SkillPlus(api_key="skp_...")
40
42
  result = client.query("https://www.skills.sh/vercel-labs/skills/find-skills")
41
43
  # GitHub repositories work the same way: "https://github.com/owner/repo"
42
44
 
43
- if result.status == "found" and result.report:
44
- print(result.report.verdict) # "safe" | "medium" | "high" | "unknown"
45
+ if result.status == "found":
46
+ print(result.report.verdict) # "safe" | "medium" | "high" | "unknown" — UI labels: Safe / Caution / High Risk / Unrated
47
+ else:
48
+ print("no report yet", "(scan in progress)" if result.scanning else "")
45
49
  ```
46
50
 
47
51
  Prefer `verdict` over the legacy `rating` field — it folds historical values
48
- onto the current three-tier scale (UI labels: safe→Safe, medium→Caution,
49
- high→High Risk, unknown→Unrated).
52
+ onto the current three-tier scale.
50
53
 
51
- ## One call that always ends with a report
54
+ ## The three integration patterns
52
55
 
53
- When you just want the final answer scanning first if the skill has never
54
- been seen — use the wait helper. It polls internally and returns the
55
- completed report:
56
+ **1. Show a report (frontend page)**pure read, instant:
56
57
 
57
58
  ```python
58
- report = client.wait_for_report("https://www.skills.sh/vercel-labs/skills/find-skills")
59
-
60
- print(report.verdict)
61
- print(report.summary.total_issues)
62
- if report.supply_chain:
63
- print(report.supply_chain.blacklist_hits) # poisoned-dependency hits
59
+ result = client.query(url) # found | not_found, never blocks
64
60
  ```
65
61
 
66
- ## Advanced options
62
+ **2. Scan in the background (batch pipelines)** — fire-and-forget, or wait:
63
+
64
+ ```python
65
+ ack = client.scan(url) # returns immediately: ack.accepted / ack.scanning
66
+ report = client.scan(url, wait=True) # blocks until the report completes, returns it
67
+ ```
67
68
 
68
- For repositories containing many skills, or to queue a scan automatically when
69
- no report exists yet:
69
+ **3. Query-and-scan (skill detail pages)** if it was never scanned, scan it:
70
70
 
71
71
  ```python
72
- result = client.query(
73
- "https://github.com/owner/repo", # multi-skill repos are usually on GitHub
74
- skill_path="skills/example", # pick one skill in the repo
75
- scan_if_missing=True, # queue a scan if no report exists
76
- )
72
+ result = client.query(url, scan_if_missing=True) # triggers a scan, still returns immediately
73
+ result = client.query(url, wait=True) # scans if needed AND waits for the report
77
74
  ```
78
75
 
79
- Client options:
76
+ `wait=True` implies `scan_if_missing`; a failing scan raises `SkillPlusError`
77
+ (errors travel on the exception channel, never as a status value).
78
+
79
+ ## Client options
80
80
 
81
81
  ```python
82
82
  client = SkillPlus(
@@ -87,24 +87,26 @@ client = SkillPlus(
87
87
  )
88
88
  ```
89
89
 
90
+ Waiting calls accept `wait_interval` (default 5 s) and `wait_timeout`
91
+ (default 600 s).
92
+
90
93
  ## Context manager
91
94
 
92
95
  ```python
93
96
  from skillplus import SkillPlus
94
97
 
95
98
  with SkillPlus(api_key="skp_...") as client:
96
- result = client.scan("https://www.skills.sh/vercel-labs/skills/find-skills")
97
- print(result.status)
99
+ ack = client.scan("https://www.skills.sh/vercel-labs/skills/find-skills")
100
+ print(ack.accepted)
98
101
  ```
99
102
 
100
103
  ## Methods
101
104
 
102
105
  | Method | Use case |
103
106
  |---|---|
104
- | `query(...)` | Check whether SkillPlus already has a report for a skill. |
105
- | `scan(...)` | Request a scan when a report is missing or when a fresh check is needed. |
106
- | `wait_for_report(...)` | Query-and-wait: scans if missing, polls, and returns the completed report. |
107
- | `get_report(scan_id)` | Retrieve structured report data: verdict, findings, AI audit, supply-chain snapshot. |
107
+ | `query(url, ...)` | Binary check: `found` (report attached) or `not_found`. Options: `scan_if_missing`, `wait`. |
108
+ | `scan(url, ...)` | Trigger a scan: fire-and-forget ack, or `wait=True` for the finished report. `force=True` re-scans fresh reports. |
109
+ | `get_report(scan_id)` | Retrieve a report by id: verdict, findings, AI audit, supply-chain snapshot. |
108
110
  | `get_badge(scan_id)` | Fetch the badge SVG for embedding. |
109
111
  | `get_badge_url(scan_id)` | Generate a badge URL for READMEs, marketplaces, or internal portals. |
110
112
 
@@ -12,7 +12,9 @@ pip install skillplus
12
12
 
13
13
  Requires Python 3.10+.
14
14
 
15
- ## Basic usage
15
+ ## Quick start
16
+
17
+ `query` gives a binary answer: is there a report, or not.
16
18
 
17
19
  ```python
18
20
  from skillplus import SkillPlus
@@ -22,43 +24,41 @@ client = SkillPlus(api_key="skp_...")
22
24
  result = client.query("https://www.skills.sh/vercel-labs/skills/find-skills")
23
25
  # GitHub repositories work the same way: "https://github.com/owner/repo"
24
26
 
25
- if result.status == "found" and result.report:
26
- print(result.report.verdict) # "safe" | "medium" | "high" | "unknown"
27
+ if result.status == "found":
28
+ print(result.report.verdict) # "safe" | "medium" | "high" | "unknown" — UI labels: Safe / Caution / High Risk / Unrated
29
+ else:
30
+ print("no report yet", "(scan in progress)" if result.scanning else "")
27
31
  ```
28
32
 
29
33
  Prefer `verdict` over the legacy `rating` field — it folds historical values
30
- onto the current three-tier scale (UI labels: safe→Safe, medium→Caution,
31
- high→High Risk, unknown→Unrated).
34
+ onto the current three-tier scale.
32
35
 
33
- ## One call that always ends with a report
36
+ ## The three integration patterns
34
37
 
35
- When you just want the final answer scanning first if the skill has never
36
- been seen — use the wait helper. It polls internally and returns the
37
- completed report:
38
+ **1. Show a report (frontend page)**pure read, instant:
38
39
 
39
40
  ```python
40
- report = client.wait_for_report("https://www.skills.sh/vercel-labs/skills/find-skills")
41
-
42
- print(report.verdict)
43
- print(report.summary.total_issues)
44
- if report.supply_chain:
45
- print(report.supply_chain.blacklist_hits) # poisoned-dependency hits
41
+ result = client.query(url) # found | not_found, never blocks
46
42
  ```
47
43
 
48
- ## Advanced options
44
+ **2. Scan in the background (batch pipelines)** — fire-and-forget, or wait:
45
+
46
+ ```python
47
+ ack = client.scan(url) # returns immediately: ack.accepted / ack.scanning
48
+ report = client.scan(url, wait=True) # blocks until the report completes, returns it
49
+ ```
49
50
 
50
- For repositories containing many skills, or to queue a scan automatically when
51
- no report exists yet:
51
+ **3. Query-and-scan (skill detail pages)** if it was never scanned, scan it:
52
52
 
53
53
  ```python
54
- result = client.query(
55
- "https://github.com/owner/repo", # multi-skill repos are usually on GitHub
56
- skill_path="skills/example", # pick one skill in the repo
57
- scan_if_missing=True, # queue a scan if no report exists
58
- )
54
+ result = client.query(url, scan_if_missing=True) # triggers a scan, still returns immediately
55
+ result = client.query(url, wait=True) # scans if needed AND waits for the report
59
56
  ```
60
57
 
61
- Client options:
58
+ `wait=True` implies `scan_if_missing`; a failing scan raises `SkillPlusError`
59
+ (errors travel on the exception channel, never as a status value).
60
+
61
+ ## Client options
62
62
 
63
63
  ```python
64
64
  client = SkillPlus(
@@ -69,24 +69,26 @@ client = SkillPlus(
69
69
  )
70
70
  ```
71
71
 
72
+ Waiting calls accept `wait_interval` (default 5 s) and `wait_timeout`
73
+ (default 600 s).
74
+
72
75
  ## Context manager
73
76
 
74
77
  ```python
75
78
  from skillplus import SkillPlus
76
79
 
77
80
  with SkillPlus(api_key="skp_...") as client:
78
- result = client.scan("https://www.skills.sh/vercel-labs/skills/find-skills")
79
- print(result.status)
81
+ ack = client.scan("https://www.skills.sh/vercel-labs/skills/find-skills")
82
+ print(ack.accepted)
80
83
  ```
81
84
 
82
85
  ## Methods
83
86
 
84
87
  | Method | Use case |
85
88
  |---|---|
86
- | `query(...)` | Check whether SkillPlus already has a report for a skill. |
87
- | `scan(...)` | Request a scan when a report is missing or when a fresh check is needed. |
88
- | `wait_for_report(...)` | Query-and-wait: scans if missing, polls, and returns the completed report. |
89
- | `get_report(scan_id)` | Retrieve structured report data: verdict, findings, AI audit, supply-chain snapshot. |
89
+ | `query(url, ...)` | Binary check: `found` (report attached) or `not_found`. Options: `scan_if_missing`, `wait`. |
90
+ | `scan(url, ...)` | Trigger a scan: fire-and-forget ack, or `wait=True` for the finished report. `force=True` re-scans fresh reports. |
91
+ | `get_report(scan_id)` | Retrieve a report by id: verdict, findings, AI audit, supply-chain snapshot. |
90
92
  | `get_badge(scan_id)` | Fetch the badge SVG for embedding. |
91
93
  | `get_badge_url(scan_id)` | Generate a badge URL for READMEs, marketplaces, or internal portals. |
92
94
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "skillplus"
7
- version = "0.1.1"
7
+ version = "0.2.0"
8
8
  description = "Official Python SDK for SkillPlus"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -11,10 +11,9 @@ from .types import (
11
11
  QueryResult,
12
12
  QueryStatus,
13
13
  Rating,
14
+ ScanAck,
14
15
  ScanIssue,
15
16
  ScanReport,
16
- ScanResult,
17
- ScanStatus,
18
17
  ScanSummary,
19
18
  Severity,
20
19
  SeverityNormalized,
@@ -34,10 +33,9 @@ __all__ = [
34
33
  "QueryResult",
35
34
  "QueryStatus",
36
35
  "Rating",
36
+ "ScanAck",
37
37
  "ScanIssue",
38
38
  "ScanReport",
39
- "ScanResult",
40
- "ScanStatus",
41
39
  "ScanSummary",
42
40
  "Severity",
43
41
  "SeverityNormalized",
@@ -13,9 +13,9 @@ from .types import (
13
13
  AiReport,
14
14
  PlatformListing,
15
15
  QueryResult,
16
+ ScanAck,
16
17
  ScanIssue,
17
18
  ScanReport,
18
- ScanResult,
19
19
  ScanSummary,
20
20
  SupplyChainHit,
21
21
  SupplyChainReport,
@@ -50,7 +50,7 @@ class SkillPlus:
50
50
  headers={
51
51
  "Authorization": f"Bearer {api_key}",
52
52
  "Content-Type": "application/json",
53
- "User-Agent": "skillplus-python/0.1.1",
53
+ "User-Agent": "skillplus-python/0.2.0",
54
54
  },
55
55
  )
56
56
 
@@ -71,21 +71,34 @@ class SkillPlus:
71
71
  *,
72
72
  skill_path: str | None = None,
73
73
  scan_if_missing: bool = False,
74
+ wait: bool = False,
75
+ wait_interval: float = 5.0,
76
+ wait_timeout: float = 600.0,
74
77
  preferred_provider: str | None = None,
75
78
  ) -> QueryResult:
76
- """Query an existing report, optionally queueing a scan if no report exists."""
79
+ """Is there a report? Binary answer: ``found`` (report attached) or
80
+ ``not_found`` (with ``scanning`` telling you whether one is on the way).
77
81
 
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
- },
82
+ - ``scan_if_missing=True`` — queue a scan when nothing exists, still
83
+ return immediately.
84
+ - ``wait=True`` — block until a completed report exists (scanning
85
+ first if needed) and return ``found``; raises on failure/timeout.
86
+ """
87
+
88
+ if wait:
89
+ return self._wait_for_completed(
90
+ repo_url,
91
+ skill_path=skill_path,
92
+ preferred_provider=preferred_provider,
93
+ prior=None,
94
+ interval=wait_interval,
95
+ timeout=wait_timeout,
96
+ )
97
+ payload = self._raw_query(
98
+ repo_url, skill_path=skill_path,
99
+ scan_if_missing=scan_if_missing, preferred_provider=preferred_provider,
87
100
  )
88
- return _parse_query_result(payload)
101
+ return _map_server_state(payload)
89
102
 
90
103
  def scan(
91
104
  self,
@@ -94,10 +107,76 @@ class SkillPlus:
94
107
  skill_path: str | None = None,
95
108
  preferred_provider: str | None = None,
96
109
  force: bool = False,
97
- ) -> ScanResult:
98
- """Request a scan for a skill."""
110
+ wait: bool = False,
111
+ wait_interval: float = 5.0,
112
+ wait_timeout: float = 600.0,
113
+ ) -> ScanAck | ScanReport:
114
+ """Trigger a scan.
115
+
116
+ - Default: fire-and-forget — returns a :class:`ScanAck` immediately
117
+ (``accepted=False`` means deduplicated: already scanning, or a
118
+ fresh report exists and ``force`` was not set).
119
+ - ``wait=True`` — block until the resulting report completes and
120
+ return the report itself.
121
+ """
122
+
123
+ if wait:
124
+ prior: ScanReport | None = None
125
+ if force:
126
+ current = self.query(repo_url, skill_path=skill_path)
127
+ prior = current.report if current.status == "found" else None
128
+ self._raw_scan(
129
+ repo_url, skill_path=skill_path,
130
+ preferred_provider=preferred_provider, force=force,
131
+ )
132
+ found = self._wait_for_completed(
133
+ repo_url,
134
+ skill_path=skill_path,
135
+ preferred_provider=preferred_provider,
136
+ prior=prior,
137
+ interval=wait_interval,
138
+ timeout=wait_timeout,
139
+ )
140
+ assert found.report is not None
141
+ return found.report
99
142
 
100
- payload = self._request_json(
143
+ payload = self._raw_scan(
144
+ repo_url, skill_path=skill_path,
145
+ preferred_provider=preferred_provider, force=force,
146
+ )
147
+ status = payload.get("status")
148
+ message = payload.get("message") or ""
149
+ if status == "queued":
150
+ return ScanAck(accepted=True, scanning=True, message=message or "Scan queued")
151
+ if status == "running":
152
+ return ScanAck(accepted=False, scanning=True, message=message or "Scan already in progress")
153
+ if status == "found":
154
+ return ScanAck(
155
+ accepted=False, scanning=False,
156
+ message="A fresh report already exists (pass force=True to re-scan)",
157
+ )
158
+ raise SkillPlusError(message or "Scan failed", detail=status)
159
+
160
+ def _raw_query(
161
+ self, repo_url: str, *, skill_path: str | None,
162
+ scan_if_missing: bool, preferred_provider: str | None,
163
+ ) -> dict[str, Any]:
164
+ return self._request_json(
165
+ "POST",
166
+ "/api/sdk/query",
167
+ json={
168
+ "repo_url": repo_url,
169
+ "skill_path": skill_path,
170
+ "scan_if_missing": scan_if_missing,
171
+ "preferred_provider": preferred_provider,
172
+ },
173
+ )
174
+
175
+ def _raw_scan(
176
+ self, repo_url: str, *, skill_path: str | None,
177
+ preferred_provider: str | None, force: bool,
178
+ ) -> dict[str, Any]:
179
+ return self._request_json(
101
180
  "POST",
102
181
  "/api/sdk/scan",
103
182
  json={
@@ -107,37 +186,43 @@ class SkillPlus:
107
186
  "force": force,
108
187
  },
109
188
  )
110
- return _parse_scan_result(payload)
111
189
 
112
- def wait_for_report(
190
+ def _wait_for_completed(
113
191
  self,
114
192
  repo_url: str,
115
193
  *,
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."""
194
+ skill_path: str | None,
195
+ preferred_provider: str | None,
196
+ prior: ScanReport | None,
197
+ interval: float,
198
+ timeout: float,
199
+ ) -> QueryResult:
200
+ """Poll until a completed report exists; a ``prior`` report (from a
201
+ forced re-scan) must be superseded before we accept an answer."""
124
202
 
125
203
  deadline = time.monotonic() + timeout
126
204
  while True:
127
- result = self.query(
128
- repo_url,
129
- skill_path=skill_path,
130
- scan_if_missing=True,
131
- preferred_provider=preferred_provider,
205
+ payload = self._raw_query(
206
+ repo_url, skill_path=skill_path,
207
+ scan_if_missing=True, preferred_provider=preferred_provider,
132
208
  )
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)
209
+ status = payload.get("status")
210
+ if status == "failed":
211
+ raise SkillPlusError(
212
+ f"Scan failed: {payload.get('message') or ''}", detail="failed"
213
+ )
214
+ report_payload = payload.get("report")
215
+ if (
216
+ status == "found"
217
+ and report_payload
218
+ and report_payload.get("status") == "completed"
219
+ and (prior is None or report_payload.get("scanned_at") != prior.scanned_at)
220
+ ):
221
+ return _map_server_state(payload)
137
222
  if time.monotonic() + interval > deadline:
138
223
  raise SkillPlusError(
139
- f"Timed out waiting for report (last status: {result.status})",
140
- detail=result.status,
224
+ f"Timed out waiting for report (last status: {status})",
225
+ detail=status,
141
226
  )
142
227
  time.sleep(interval)
143
228
 
@@ -219,23 +304,21 @@ class SkillPlus:
219
304
  return response
220
305
 
221
306
 
222
- def _parse_query_result(payload: dict[str, Any]) -> QueryResult:
307
+ def _map_server_state(payload: dict[str, Any]) -> QueryResult:
308
+ """Fold the server's five-state machine onto the binary query contract."""
309
+ status = payload.get("status")
310
+ if status == "found" and payload.get("report"):
311
+ return QueryResult(
312
+ status="found",
313
+ message=payload.get("message") or "Report found",
314
+ report=_parse_report(payload["report"]),
315
+ scanning=False,
316
+ )
223
317
  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,
318
+ status="not_found",
319
+ message=payload.get("message") or "Report not found",
320
+ report=None,
321
+ scanning=status in ("queued", "running"),
239
322
  )
240
323
 
241
324
 
@@ -23,8 +23,8 @@ IssueCategory: TypeAlias = Literal[
23
23
  "supply_chain",
24
24
  "propagation",
25
25
  ]
26
- QueryStatus: TypeAlias = Literal["found", "queued", "running", "not_found", "failed"]
27
- ScanStatus: TypeAlias = Literal["found", "queued", "running", "failed"]
26
+ # query() is binary: a report exists or it does not.
27
+ QueryStatus: TypeAlias = Literal["found", "not_found"]
28
28
  AiReportStatus: TypeAlias = Literal["pending", "processing", "completed", "failed"]
29
29
 
30
30
 
@@ -160,17 +160,25 @@ class ScanReport:
160
160
 
161
161
  @dataclass(slots=True)
162
162
  class QueryResult:
163
+ """Binary answer: ``found`` (report attached) or ``not_found``.
164
+
165
+ ``scanning`` is a courtesy flag on not_found: True means a scan for this
166
+ skill is queued or running right now (check back shortly)."""
167
+
163
168
  status: QueryStatus
164
169
  message: str
165
- scan_id: str | None = None
166
- poll_url: str | None = None
167
170
  report: ScanReport | None = None
171
+ scanning: bool = False
168
172
 
169
173
 
170
174
  @dataclass(slots=True)
171
- class ScanResult:
172
- status: ScanStatus
175
+ class ScanAck:
176
+ """Acknowledgement of a fire-and-forget scan() call.
177
+
178
+ ``accepted`` is True only when THIS call queued a new scan; False means
179
+ it was deduplicated (already scanning, or a fresh report exists and
180
+ ``force`` was not set)."""
181
+
182
+ accepted: bool
183
+ scanning: bool
173
184
  message: str
174
- scan_id: str | None = None
175
- poll_url: str | None = None
176
- report: ScanReport | None = None
@@ -0,0 +1,241 @@
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
+ SERVER_QUEUED = {"status": "queued", "message": "Scan queued", "scan_id": None, "poll_url": None}
81
+ SERVER_RUNNING = {"status": "running", "message": "Scan already in progress", "scan_id": "s1", "poll_url": "/x"}
82
+ SERVER_NOT_FOUND = {"status": "not_found", "message": "Report not found", "scan_id": None, "poll_url": None}
83
+ SERVER_FAILED = {"status": "failed", "message": "boom", "scan_id": None, "poll_url": None}
84
+
85
+
86
+ def server_found(report=None):
87
+ return {
88
+ "status": "found",
89
+ "message": "Report found",
90
+ "scan_id": "scan_123",
91
+ "poll_url": "/api/scan/status/scan_123",
92
+ "report": report or REPORT_PAYLOAD,
93
+ }
94
+
95
+
96
+ @respx.mock
97
+ def test_query_sends_auth_and_request_body() -> None:
98
+ route = respx.post("https://skillplus.xyz/api/sdk/query").mock(
99
+ return_value=Response(200, json=server_found())
100
+ )
101
+ with SkillPlus(api_key="skp_test") as client:
102
+ result = client.query(
103
+ "https://github.com/owner/repo",
104
+ skill_path="skills/example",
105
+ scan_if_missing=True,
106
+ preferred_provider="github",
107
+ )
108
+ request = route.calls.last.request
109
+ assert request.headers["Authorization"] == "Bearer skp_test"
110
+ assert json.loads(request.read()) == {
111
+ "repo_url": "https://github.com/owner/repo",
112
+ "skill_path": "skills/example",
113
+ "scan_if_missing": True,
114
+ "preferred_provider": "github",
115
+ }
116
+ assert result.status == "found"
117
+ assert result.scanning is False
118
+ assert result.report is not None
119
+ assert result.report.scan_id == "scan_123"
120
+ assert result.report.summary.total_issues == 1
121
+ assert result.report.issues[0].rule_id == "external-url"
122
+
123
+
124
+ @respx.mock
125
+ def test_query_folds_in_progress_onto_not_found_scanning() -> None:
126
+ for server, scanning in ((SERVER_QUEUED, True), (SERVER_RUNNING, True),
127
+ (SERVER_NOT_FOUND, False), (SERVER_FAILED, False)):
128
+ respx.post("https://skillplus.xyz/api/sdk/query").mock(
129
+ return_value=Response(200, json=server)
130
+ )
131
+ with SkillPlus(api_key="skp_test") as client:
132
+ result = client.query("https://skills.sh/o/r/s")
133
+ assert result.status == "not_found"
134
+ assert result.report is None
135
+ assert result.scanning is scanning
136
+
137
+
138
+ @respx.mock
139
+ def test_query_wait_polls_until_completed() -> None:
140
+ route = respx.post("https://skillplus.xyz/api/sdk/query")
141
+ route.side_effect = [
142
+ Response(200, json=SERVER_QUEUED),
143
+ Response(200, json=SERVER_RUNNING),
144
+ Response(200, json=server_found()),
145
+ ]
146
+ with SkillPlus(api_key="skp_test") as client:
147
+ result = client.query("https://skills.sh/o/r/s", wait=True, wait_interval=0.01)
148
+ assert result.status == "found"
149
+ assert result.report is not None
150
+ assert route.call_count == 3
151
+
152
+
153
+ @respx.mock
154
+ def test_query_wait_raises_on_scan_failure() -> None:
155
+ route = respx.post("https://skillplus.xyz/api/sdk/query")
156
+ route.side_effect = [Response(200, json=SERVER_QUEUED), Response(200, json=SERVER_FAILED)]
157
+ with SkillPlus(api_key="skp_test") as client:
158
+ with pytest.raises(SkillPlusError) as excinfo:
159
+ client.query("https://skills.sh/o/r/s", wait=True, wait_interval=0.01)
160
+ assert "failed" in str(excinfo.value).lower()
161
+
162
+
163
+ @respx.mock
164
+ def test_query_wait_times_out() -> None:
165
+ respx.post("https://skillplus.xyz/api/sdk/query").mock(
166
+ return_value=Response(200, json=SERVER_QUEUED)
167
+ )
168
+ with SkillPlus(api_key="skp_test") as client:
169
+ with pytest.raises(SkillPlusError) as excinfo:
170
+ client.query("https://skills.sh/o/r/s", wait=True, wait_interval=0.01, wait_timeout=0.02)
171
+ assert "Timed out" in str(excinfo.value)
172
+
173
+
174
+ @respx.mock
175
+ def test_scan_ack_semantics() -> None:
176
+ for server, accepted, scanning in ((SERVER_QUEUED, True, True),
177
+ (SERVER_RUNNING, False, True),
178
+ (server_found(), False, False)):
179
+ respx.post("https://skillplus.xyz/api/sdk/scan").mock(
180
+ return_value=Response(200, json=server)
181
+ )
182
+ with SkillPlus(api_key="skp_test") as client:
183
+ ack = client.scan("https://skills.sh/o/r/s")
184
+ assert (ack.accepted, ack.scanning) == (accepted, scanning)
185
+
186
+
187
+ @respx.mock
188
+ def test_scan_wait_triggers_then_returns_report() -> None:
189
+ scan_route = respx.post("https://skillplus.xyz/api/sdk/scan").mock(
190
+ return_value=Response(200, json=SERVER_QUEUED)
191
+ )
192
+ query_route = respx.post("https://skillplus.xyz/api/sdk/query")
193
+ query_route.side_effect = [Response(200, json=SERVER_RUNNING), Response(200, json=server_found())]
194
+ with SkillPlus(api_key="skp_test") as client:
195
+ report = client.scan("https://skills.sh/o/r/s", wait=True, wait_interval=0.01)
196
+ assert report.scan_id == "scan_123"
197
+ assert scan_route.call_count == 1
198
+ assert query_route.call_count == 2
199
+
200
+
201
+ @respx.mock
202
+ def test_scan_wait_force_waits_for_superseding_report() -> None:
203
+ import copy
204
+ old = copy.deepcopy(REPORT_PAYLOAD)
205
+ new = copy.deepcopy(REPORT_PAYLOAD)
206
+ new["scanned_at"] = "2026-07-18T12:00:00Z"
207
+ respx.post("https://skillplus.xyz/api/sdk/scan").mock(
208
+ return_value=Response(200, json=SERVER_QUEUED)
209
+ )
210
+ query_route = respx.post("https://skillplus.xyz/api/sdk/query")
211
+ query_route.side_effect = [
212
+ Response(200, json=server_found(old)), # prior capture
213
+ Response(200, json=server_found(old)), # still the old report
214
+ Response(200, json=server_found(new)), # rescanned
215
+ ]
216
+ with SkillPlus(api_key="skp_test") as client:
217
+ report = client.scan("https://skills.sh/o/r/s", force=True, wait=True, wait_interval=0.01)
218
+ assert report.scanned_at == "2026-07-18T12:00:00Z"
219
+ assert query_route.call_count == 3
220
+
221
+
222
+ @respx.mock
223
+ def test_http_error_raises_skillplus_error() -> None:
224
+ respx.post("https://skillplus.xyz/api/sdk/query").mock(
225
+ return_value=Response(401, json={"detail": "Invalid API key"})
226
+ )
227
+ with SkillPlus(api_key="skp_bad") as client:
228
+ with pytest.raises(SkillPlusError) as excinfo:
229
+ client.query("https://skills.sh/o/r/s")
230
+ assert excinfo.value.status_code == 401
231
+ assert "Invalid API key" in str(excinfo.value)
232
+
233
+
234
+ @respx.mock
235
+ def test_get_badge_and_badge_url() -> None:
236
+ respx.get("https://skillplus.xyz/api/report/scan_123/badge.svg").mock(
237
+ return_value=Response(200, text="<svg/>")
238
+ )
239
+ with SkillPlus(api_key="skp_test") as client:
240
+ assert client.get_badge("scan_123") == "<svg/>"
241
+ assert client.get_badge_url("scan_123") == "https://skillplus.xyz/api/report/scan_123/badge.svg"
@@ -141,7 +141,7 @@ def test_no_retry_when_disabled() -> None:
141
141
 
142
142
 
143
143
  @respx.mock
144
- def test_wait_for_report_polls_until_completed() -> None:
144
+ def test_query_wait_polls_until_completed_v3() -> None:
145
145
  queued = {"status": "queued", "message": "Scan queued", "scan_id": None, "poll_url": None}
146
146
  found = {
147
147
  "status": "found",
@@ -154,23 +154,21 @@ def test_wait_for_report_polls_until_completed() -> None:
154
154
  route.side_effect = [Response(200, json=queued), Response(200, json=found)]
155
155
 
156
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
- )
157
+ result = client.query("https://github.com/owner/repo", wait=True, wait_interval=0.01)
160
158
 
161
- assert report.scan_id == "scan_123"
159
+ assert result.report is not None and result.report.scan_id == "scan_123"
162
160
  assert route.call_count == 2
163
161
 
164
162
 
165
163
  @respx.mock
166
- def test_wait_for_report_times_out() -> None:
164
+ def test_query_wait_times_out_v3() -> None:
167
165
  queued = {"status": "queued", "message": "Scan queued", "scan_id": None, "poll_url": None}
168
166
  respx.post("https://skillplus.xyz/api/sdk/query").mock(
169
167
  return_value=Response(200, json=queued)
170
168
  )
171
169
  with SkillPlus(api_key="skp_test") as client:
172
170
  with pytest.raises(SkillPlusError) as excinfo:
173
- client.wait_for_report("https://github.com/owner/repo", interval=0.01, timeout=0.02)
171
+ client.query("https://github.com/owner/repo", wait=True, wait_interval=0.01, wait_timeout=0.02)
174
172
  assert "Timed out" in str(excinfo.value)
175
173
 
176
174
 
@@ -1,223 +0,0 @@
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"
File without changes
File without changes