skillplus 0.1.2__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.2
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
@@ -32,52 +32,51 @@ Requires Python 3.10+.
32
32
 
33
33
  ## Quick start
34
34
 
35
- One call that always ends with a completed report scanning first if the
36
- skill has never been seen:
35
+ `query` gives a binary answer: is there a report, or not.
37
36
 
38
37
  ```python
39
38
  from skillplus import SkillPlus
40
39
 
41
40
  client = SkillPlus(api_key="skp_...")
42
41
 
43
- report = client.wait_for_report("https://www.skills.sh/vercel-labs/skills/find-skills")
42
+ result = client.query("https://www.skills.sh/vercel-labs/skills/find-skills")
44
43
  # GitHub repositories work the same way: "https://github.com/owner/repo"
45
44
 
46
- print(report.verdict) # "safe" | "medium" | "high" | "unknown" — UI labels: Safe / Caution / High Risk / Unrated
47
- print(report.summary.total_issues)
48
- if report.supply_chain:
49
- print(report.supply_chain.blacklist_hits) # poisoned-dependency hits
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 "")
50
49
  ```
51
50
 
52
51
  Prefer `verdict` over the legacy `rating` field — it folds historical values
53
52
  onto the current three-tier scale.
54
53
 
55
- ## Lower-level: check without waiting
54
+ ## The three integration patterns
56
55
 
57
- `query` returns immediately with the current state (`found` / `queued` /
58
- `running` / `not_found` / `failed`) and never blocks:
56
+ **1. Show a report (frontend page)** pure read, instant:
59
57
 
60
58
  ```python
61
- result = client.query("https://www.skills.sh/vercel-labs/skills/find-skills")
62
-
63
- if result.status == "found" and result.report:
64
- print(result.report.verdict)
59
+ result = client.query(url) # found | not_found, never blocks
65
60
  ```
66
61
 
67
- ## Advanced options
62
+ **2. Scan in the background (batch pipelines)** — fire-and-forget, or wait:
68
63
 
69
- For repositories containing many skills, or to queue a scan automatically when
70
- no report exists yet:
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
+ ```
68
+
69
+ **3. Query-and-scan (skill detail pages)** — if it was never scanned, scan it:
71
70
 
72
71
  ```python
73
- result = client.query(
74
- "https://github.com/owner/repo", # multi-skill repos are usually on GitHub
75
- skill_path="skills/example", # pick one skill in the repo
76
- scan_if_missing=True, # queue a scan if no report exists
77
- )
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
78
74
  ```
79
75
 
80
- 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
81
80
 
82
81
  ```python
83
82
  client = SkillPlus(
@@ -88,24 +87,26 @@ client = SkillPlus(
88
87
  )
89
88
  ```
90
89
 
90
+ Waiting calls accept `wait_interval` (default 5 s) and `wait_timeout`
91
+ (default 600 s).
92
+
91
93
  ## Context manager
92
94
 
93
95
  ```python
94
96
  from skillplus import SkillPlus
95
97
 
96
98
  with SkillPlus(api_key="skp_...") as client:
97
- result = client.scan("https://www.skills.sh/vercel-labs/skills/find-skills")
98
- print(result.status)
99
+ ack = client.scan("https://www.skills.sh/vercel-labs/skills/find-skills")
100
+ print(ack.accepted)
99
101
  ```
100
102
 
101
103
  ## Methods
102
104
 
103
105
  | Method | Use case |
104
106
  |---|---|
105
- | `query(...)` | Check whether SkillPlus already has a report for a skill. |
106
- | `scan(...)` | Request a scan when a report is missing or when a fresh check is needed. |
107
- | `wait_for_report(...)` | Query-and-wait: scans if missing, polls, and returns the completed report. |
108
- | `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. |
109
110
  | `get_badge(scan_id)` | Fetch the badge SVG for embedding. |
110
111
  | `get_badge_url(scan_id)` | Generate a badge URL for READMEs, marketplaces, or internal portals. |
111
112
 
@@ -14,52 +14,51 @@ Requires Python 3.10+.
14
14
 
15
15
  ## Quick start
16
16
 
17
- One call that always ends with a completed report scanning first if the
18
- skill has never been seen:
17
+ `query` gives a binary answer: is there a report, or not.
19
18
 
20
19
  ```python
21
20
  from skillplus import SkillPlus
22
21
 
23
22
  client = SkillPlus(api_key="skp_...")
24
23
 
25
- report = client.wait_for_report("https://www.skills.sh/vercel-labs/skills/find-skills")
24
+ result = client.query("https://www.skills.sh/vercel-labs/skills/find-skills")
26
25
  # GitHub repositories work the same way: "https://github.com/owner/repo"
27
26
 
28
- print(report.verdict) # "safe" | "medium" | "high" | "unknown" — UI labels: Safe / Caution / High Risk / Unrated
29
- print(report.summary.total_issues)
30
- if report.supply_chain:
31
- print(report.supply_chain.blacklist_hits) # poisoned-dependency hits
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 "")
32
31
  ```
33
32
 
34
33
  Prefer `verdict` over the legacy `rating` field — it folds historical values
35
34
  onto the current three-tier scale.
36
35
 
37
- ## Lower-level: check without waiting
36
+ ## The three integration patterns
38
37
 
39
- `query` returns immediately with the current state (`found` / `queued` /
40
- `running` / `not_found` / `failed`) and never blocks:
38
+ **1. Show a report (frontend page)** pure read, instant:
41
39
 
42
40
  ```python
43
- result = client.query("https://www.skills.sh/vercel-labs/skills/find-skills")
44
-
45
- if result.status == "found" and result.report:
46
- print(result.report.verdict)
41
+ result = client.query(url) # found | not_found, never blocks
47
42
  ```
48
43
 
49
- ## Advanced options
44
+ **2. Scan in the background (batch pipelines)** — fire-and-forget, or wait:
50
45
 
51
- For repositories containing many skills, or to queue a scan automatically when
52
- no report exists yet:
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
+ ```
50
+
51
+ **3. Query-and-scan (skill detail pages)** — if it was never scanned, scan it:
53
52
 
54
53
  ```python
55
- result = client.query(
56
- "https://github.com/owner/repo", # multi-skill repos are usually on GitHub
57
- skill_path="skills/example", # pick one skill in the repo
58
- scan_if_missing=True, # queue a scan if no report exists
59
- )
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
60
56
  ```
61
57
 
62
- 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
63
62
 
64
63
  ```python
65
64
  client = SkillPlus(
@@ -70,24 +69,26 @@ client = SkillPlus(
70
69
  )
71
70
  ```
72
71
 
72
+ Waiting calls accept `wait_interval` (default 5 s) and `wait_timeout`
73
+ (default 600 s).
74
+
73
75
  ## Context manager
74
76
 
75
77
  ```python
76
78
  from skillplus import SkillPlus
77
79
 
78
80
  with SkillPlus(api_key="skp_...") as client:
79
- result = client.scan("https://www.skills.sh/vercel-labs/skills/find-skills")
80
- print(result.status)
81
+ ack = client.scan("https://www.skills.sh/vercel-labs/skills/find-skills")
82
+ print(ack.accepted)
81
83
  ```
82
84
 
83
85
  ## Methods
84
86
 
85
87
  | Method | Use case |
86
88
  |---|---|
87
- | `query(...)` | Check whether SkillPlus already has a report for a skill. |
88
- | `scan(...)` | Request a scan when a report is missing or when a fresh check is needed. |
89
- | `wait_for_report(...)` | Query-and-wait: scans if missing, polls, and returns the completed report. |
90
- | `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. |
91
92
  | `get_badge(scan_id)` | Fetch the badge SVG for embedding. |
92
93
  | `get_badge_url(scan_id)` | Generate a badge URL for READMEs, marketplaces, or internal portals. |
93
94
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "skillplus"
7
- version = "0.1.2"
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.2",
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