slopguard-cli 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.
Files changed (50) hide show
  1. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/PKG-INFO +1 -1
  2. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/pyproject.toml +1 -1
  3. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/__init__.py +1 -1
  4. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/cli.py +183 -2
  5. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/data/__init__.py +26 -2
  6. slopguard_cli-0.2.0/slopguard/saas/__init__.py +26 -0
  7. slopguard_cli-0.2.0/slopguard/saas/client.py +114 -0
  8. slopguard_cli-0.2.0/slopguard/saas/credentials.py +118 -0
  9. slopguard_cli-0.2.0/slopguard/update.py +94 -0
  10. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/tests/test_cli.py +2 -2
  11. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/tests/test_misc.py +16 -4
  12. slopguard_cli-0.2.0/tests/test_saas_auth.py +190 -0
  13. slopguard_cli-0.2.0/tests/test_saas_client.py +186 -0
  14. slopguard_cli-0.2.0/tests/test_update.py +141 -0
  15. slopguard_cli-0.1.1/slopguard/update.py +0 -15
  16. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/.gitignore +0 -0
  17. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/.ruff.toml +0 -0
  18. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/Makefile +0 -0
  19. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/README.md +0 -0
  20. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/scripts/generate_seed_data.py +0 -0
  21. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/__main__.py +0 -0
  22. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/config.py +0 -0
  23. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/data/hallucinations_seed.json +0 -0
  24. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/data/popular_packages.json +0 -0
  25. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/models.py +0 -0
  26. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/parsers/__init__.py +0 -0
  27. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/parsers/base.py +0 -0
  28. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/parsers/npm.py +0 -0
  29. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/parsers/python.py +0 -0
  30. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/registry/__init__.py +0 -0
  31. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/registry/base.py +0 -0
  32. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/registry/npm.py +0 -0
  33. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/registry/pypi.py +0 -0
  34. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/report/__init__.py +0 -0
  35. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/report/json.py +0 -0
  36. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/report/terminal.py +0 -0
  37. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/scoring/__init__.py +0 -0
  38. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/scoring/engine.py +0 -0
  39. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/slopguard/scoring/signals.py +0 -0
  40. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/tests/__init__.py +0 -0
  41. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/tests/conftest.py +0 -0
  42. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/tests/fixtures/.slopguard.yaml +0 -0
  43. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/tests/fixtures/package.json +0 -0
  44. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/tests/fixtures/pyproject.toml +0 -0
  45. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/tests/fixtures/requirements.txt +0 -0
  46. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/tests/test_parsers_npm.py +0 -0
  47. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/tests/test_parsers_python.py +0 -0
  48. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/tests/test_registry_npm.py +0 -0
  49. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/tests/test_registry_pypi.py +0 -0
  50. {slopguard_cli-0.1.1 → slopguard_cli-0.2.0}/tests/test_scoring_engine.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: slopguard-cli
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Defend developers and AI coding agents against slopsquatting (hallucinated package names).
5
5
  Project-URL: Homepage, https://github.com/hariomunknownslab/slopguard
6
6
  Project-URL: Repository, https://github.com/hariomunknownslab/slopguard
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "slopguard-cli"
7
- version = "0.1.1"
7
+ version = "0.2.0"
8
8
  description = "Defend developers and AI coding agents against slopsquatting (hallucinated package names)."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -2,6 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.1.1"
5
+ __version__ = "0.2.0"
6
6
 
7
7
  __all__ = ["__version__"]
@@ -270,6 +270,13 @@ def scan_cmd(
270
270
  int | None, typer.Option("--concurrency", help="Maximum concurrent registry probes.")
271
271
  ] = None,
272
272
  verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Show debug logs.")] = False,
273
+ upload: Annotated[
274
+ bool,
275
+ typer.Option(
276
+ "--upload",
277
+ help="Upload the scan to the SlopGuard SaaS after scanning. Requires `slopguard login`.",
278
+ ),
279
+ ] = False,
273
280
  ) -> None:
274
281
  """Scan a project for slopsquatted / hallucinated dependencies."""
275
282
  if verbose:
@@ -304,18 +311,192 @@ def scan_cmd(
304
311
  else:
305
312
  render_terminal_report(report, duration_seconds=duration)
306
313
 
314
+ if upload:
315
+ _upload_report(report, duration_ms=int(duration * 1000))
316
+
307
317
  raise typer.Exit(code=report.exit_code)
308
318
 
309
319
 
320
+ def _upload_report(report: ScanReport, *, duration_ms: int) -> None:
321
+ """Persist a completed ScanReport to the SaaS via POST /v1/scans."""
322
+ from slopguard.saas import load_credentials
323
+ from slopguard.saas.client import ApiError, list_projects, post_scan
324
+
325
+ creds = load_credentials()
326
+ if creds is None:
327
+ raise _error_exit("upload requires `slopguard login` first.")
328
+ if creds.organization_id is None:
329
+ raise _error_exit(
330
+ "credentials missing organization_id; run `slopguard logout` then `slopguard login` again."
331
+ )
332
+
333
+ try:
334
+ projects = list_projects(creds.api_url, creds.token, creds.organization_id)
335
+ except ApiError as exc:
336
+ raise _error_exit(f"could not list projects: {exc.detail} (status {exc.status})") from exc
337
+ if not projects:
338
+ raise _error_exit("no project on this organization; visit the dashboard and create one.")
339
+ project_id = projects[0]["id"]
340
+
341
+ payload: dict[str, object] = {
342
+ "project_id": project_id,
343
+ "report": report.model_dump(mode="json"),
344
+ "source": _detect_runner(),
345
+ "duration_ms": duration_ms,
346
+ }
347
+ try:
348
+ created = post_scan(creds.api_url, creds.token, payload)
349
+ except ApiError as exc:
350
+ raise _error_exit(f"upload failed: {exc.detail} (status {exc.status})") from exc
351
+
352
+ Console().print(
353
+ f"[green]Uploaded[/green] to {creds.api_url}/app/{creds.org_slug}/scans/{created['id']}"
354
+ )
355
+
356
+
357
+ def _detect_runner() -> str:
358
+ import os
359
+
360
+ if os.environ.get("GITHUB_ACTIONS") == "true":
361
+ return "ci"
362
+ if os.environ.get("CI") == "true":
363
+ return "ci"
364
+ return "cli"
365
+
366
+
310
367
  @app.command("version")
311
368
  def version_cmd() -> None:
312
369
  """Print the SlopGuard version and exit 0."""
313
370
  print(__version__)
314
371
 
315
372
 
316
- @app.command("update", hidden=True)
373
+ @app.command("update")
317
374
  def update_cmd() -> None:
318
- """(Stub) refresh the embedded hallucination database. See TODO(v0.2)."""
375
+ """Refresh the local hallucination DB from the SlopGuard SaaS feed.
376
+
377
+ No auth required. Downloads the public registry from
378
+ ``$SLOPGUARD_API_URL/v1/hallucinations`` (default: the SlopGuard SaaS)
379
+ and caches it at ``~/.cache/slopguard/hallucinations_db.json`` for the
380
+ next scan to read.
381
+ """
319
382
  from slopguard.update import run
320
383
 
321
384
  raise typer.Exit(code=run())
385
+
386
+
387
+ # --- SaaS auth subcommands -------------------------------------------------
388
+ #
389
+ # Free, unauthenticated scanning never touches these — the imports below are
390
+ # deferred so the CLI keeps starting fast and offline.
391
+
392
+
393
+ def _resolve_api_url(flag_value: str | None) -> str:
394
+ """API URL resolution order: --api-url > $SLOPGUARD_API_URL > default."""
395
+ import os
396
+
397
+ from slopguard.saas.client import DEFAULT_API_URL
398
+
399
+ return flag_value or os.environ.get("SLOPGUARD_API_URL") or DEFAULT_API_URL
400
+
401
+
402
+ @app.command("login")
403
+ def login_cmd(
404
+ token: Annotated[
405
+ str | None,
406
+ typer.Option(
407
+ "--token",
408
+ help="API token. If omitted, you'll be prompted (paste hidden).",
409
+ ),
410
+ ] = None,
411
+ api_url: Annotated[
412
+ str | None,
413
+ typer.Option(
414
+ "--api-url",
415
+ help="SlopGuard API base URL. Defaults to $SLOPGUARD_API_URL or production.",
416
+ ),
417
+ ] = None,
418
+ ) -> None:
419
+ """Authenticate the CLI with a SlopGuard API token.
420
+
421
+ Mint a token in the dashboard at *<api>/app/<org>/tokens* and paste it
422
+ here. The token is stored at ``~/.config/slopguard/credentials.toml``
423
+ with mode 0600.
424
+ """
425
+ from slopguard.saas import Credentials, save_credentials
426
+ from slopguard.saas.client import ApiError, get_me
427
+
428
+ api = _resolve_api_url(api_url)
429
+ plaintext = token or typer.prompt("API token (input hidden)", hide_input=True)
430
+ if not plaintext.startswith(("sg_live_", "sg_test_")):
431
+ raise _error_exit(
432
+ "token must start with sg_live_ or sg_test_; mint one at "
433
+ f"{api.rstrip('/')}/app/<org-slug>/tokens"
434
+ )
435
+
436
+ try:
437
+ me = get_me(api, plaintext)
438
+ except ApiError as exc:
439
+ raise _error_exit(
440
+ f"could not verify token against {api}: {exc.detail} (status {exc.status})"
441
+ ) from exc
442
+
443
+ save_credentials(
444
+ Credentials(
445
+ api_url=api,
446
+ token=plaintext,
447
+ org_slug=me.active_org.slug,
448
+ organization_id=me.active_org.id,
449
+ user_email=me.email,
450
+ )
451
+ )
452
+
453
+ console = Console()
454
+ console.print(
455
+ f"[green]Authenticated.[/green] Org: [bold]{me.active_org.name}[/bold] "
456
+ f"([cyan]{me.active_org.slug}[/cyan], plan: {me.active_org.plan})"
457
+ )
458
+ console.print("Credentials saved to ~/.config/slopguard/credentials.toml (mode 600).")
459
+
460
+
461
+ @app.command("whoami")
462
+ def whoami_cmd() -> None:
463
+ """Print the active org context for the saved credentials."""
464
+ from slopguard.saas import load_credentials
465
+ from slopguard.saas.client import ApiError, get_me
466
+
467
+ creds = load_credentials()
468
+ if creds is None:
469
+ raise _error_exit(
470
+ "not authenticated. Run `slopguard login` first.",
471
+ )
472
+
473
+ try:
474
+ me = get_me(creds.api_url, creds.token)
475
+ except ApiError as exc:
476
+ if exc.status in (401, 403):
477
+ raise _error_exit(
478
+ "saved token rejected. Run `slopguard logout` then `slopguard login` again."
479
+ ) from exc
480
+ raise _error_exit(f"API call failed: {exc.detail} (status {exc.status})") from exc
481
+
482
+ console = Console()
483
+ console.print(f"[bold]Email:[/bold] {me.email or '—'}")
484
+ console.print(
485
+ f"[bold]Active org:[/bold] {me.active_org.name} ([cyan]{me.active_org.slug}[/cyan])"
486
+ )
487
+ console.print(f"[bold]Role:[/bold] {me.active_org.role}")
488
+ console.print(f"[bold]Plan:[/bold] {me.active_org.plan}")
489
+ console.print(f"[bold]API:[/bold] {creds.api_url}")
490
+
491
+
492
+ @app.command("logout")
493
+ def logout_cmd() -> None:
494
+ """Forget the saved credentials."""
495
+ from slopguard.saas import delete_credentials
496
+
497
+ removed = delete_credentials()
498
+ console = Console()
499
+ if removed:
500
+ console.print("[green]Logged out.[/green] Credentials file removed.")
501
+ else:
502
+ console.print("[yellow]Not logged in.[/yellow] No credentials file to remove.")
@@ -5,14 +5,38 @@ from __future__ import annotations
5
5
  import json
6
6
  from functools import lru_cache
7
7
  from importlib.resources import files
8
+ from pathlib import Path
8
9
 
9
10
  from slopguard.models import Ecosystem, HallucinationDB, HallucinationEntry, PopularPackages
10
11
 
12
+ # Override path written by ``slopguard update``. When present, it takes
13
+ # precedence over the bundled seed so users get the freshest list
14
+ # without reinstalling.
15
+ CACHE_OVERRIDE = Path.home() / ".cache" / "slopguard" / "hallucinations_db.json"
16
+
11
17
 
12
18
  @lru_cache(maxsize=1)
13
19
  def load_hallucination_db() -> HallucinationDB:
14
- """Load and validate the embedded hallucination seed database."""
15
- raw = files("slopguard.data").joinpath("hallucinations_seed.json").read_text(encoding="utf-8")
20
+ """Load and validate the hallucination database.
21
+
22
+ Prefers the user cache (refreshed by ``slopguard update``) over the
23
+ embedded seed. Falls back transparently if the cache is missing or
24
+ malformed.
25
+ """
26
+ raw: str
27
+ if CACHE_OVERRIDE.is_file():
28
+ try:
29
+ raw = CACHE_OVERRIDE.read_text(encoding="utf-8")
30
+ except OSError:
31
+ raw = (
32
+ files("slopguard.data")
33
+ .joinpath("hallucinations_seed.json")
34
+ .read_text(encoding="utf-8")
35
+ )
36
+ else:
37
+ raw = (
38
+ files("slopguard.data").joinpath("hallucinations_seed.json").read_text(encoding="utf-8")
39
+ )
16
40
  payload = json.loads(raw)
17
41
  # Strip non-schema keys (operator notes, etc.) before validating.
18
42
  keep = {"schema_version", "updated", "entries"}
@@ -0,0 +1,26 @@
1
+ """SaaS integration: credentials store + HTTP client for the SlopGuard API.
2
+
3
+ Free, unauthenticated CLI invocations never touch this module. It is only
4
+ imported when the user runs ``slopguard login``, ``slopguard whoami``,
5
+ ``slopguard logout``, or ``slopguard scan --upload``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from slopguard.saas.credentials import (
11
+ DEFAULT_CREDENTIALS_PATH,
12
+ Credentials,
13
+ CredentialsError,
14
+ delete_credentials,
15
+ load_credentials,
16
+ save_credentials,
17
+ )
18
+
19
+ __all__ = [
20
+ "DEFAULT_CREDENTIALS_PATH",
21
+ "Credentials",
22
+ "CredentialsError",
23
+ "delete_credentials",
24
+ "load_credentials",
25
+ "save_credentials",
26
+ ]
@@ -0,0 +1,114 @@
1
+ """Tiny HTTP client for the SlopGuard API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ DEFAULT_API_URL = "https://api.slopguard.io"
11
+
12
+
13
+ class ApiError(Exception):
14
+ def __init__(self, status: int, detail: str) -> None:
15
+ self.status = status
16
+ self.detail = detail
17
+ super().__init__(f"API {status}: {detail}")
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class MeOrg:
22
+ id: str
23
+ name: str
24
+ slug: str
25
+ plan: str
26
+ role: str
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class MeResponse:
31
+ user_id: str | None
32
+ email: str | None
33
+ principal_kind: str
34
+ active_org: MeOrg
35
+
36
+
37
+ def get_me(api_url: str, token: str, *, timeout: float = 10.0) -> MeResponse:
38
+ """Synchronous /v1/me call. Used by login/whoami/--upload."""
39
+ try:
40
+ response = httpx.get(
41
+ f"{api_url.rstrip('/')}/v1/me",
42
+ headers={"Authorization": f"Bearer {token}"},
43
+ timeout=timeout,
44
+ )
45
+ except httpx.RequestError as exc:
46
+ raise ApiError(0, f"network error: {exc}") from exc
47
+
48
+ if response.status_code != 200:
49
+ detail = _extract_detail(response)
50
+ raise ApiError(response.status_code, detail)
51
+ payload = response.json()
52
+ active = payload["active_organization"]
53
+ return MeResponse(
54
+ user_id=payload.get("user_id"),
55
+ email=payload.get("email"),
56
+ principal_kind=payload["principal_kind"],
57
+ active_org=MeOrg(
58
+ id=active["id"],
59
+ name=active["name"],
60
+ slug=active["slug"],
61
+ plan=active["plan"],
62
+ role=active["role"],
63
+ ),
64
+ )
65
+
66
+
67
+ def post_scan(
68
+ api_url: str, token: str, payload: dict[str, Any], *, timeout: float = 30.0
69
+ ) -> dict[str, Any]:
70
+ """POST /v1/scans. Returns the parsed ScanSummary response."""
71
+ try:
72
+ response = httpx.post(
73
+ f"{api_url.rstrip('/')}/v1/scans",
74
+ headers={
75
+ "Authorization": f"Bearer {token}",
76
+ "Content-Type": "application/json",
77
+ },
78
+ json=payload,
79
+ timeout=timeout,
80
+ )
81
+ except httpx.RequestError as exc:
82
+ raise ApiError(0, f"network error: {exc}") from exc
83
+ if response.status_code not in (200, 201):
84
+ raise ApiError(response.status_code, _extract_detail(response))
85
+ return response.json() # type: ignore[no-any-return]
86
+
87
+
88
+ def list_projects(
89
+ api_url: str, token: str, organization_id: str, *, timeout: float = 10.0
90
+ ) -> list[dict[str, Any]]:
91
+ try:
92
+ response = httpx.get(
93
+ f"{api_url.rstrip('/')}/v1/projects",
94
+ params={"organization_id": organization_id},
95
+ headers={"Authorization": f"Bearer {token}"},
96
+ timeout=timeout,
97
+ )
98
+ except httpx.RequestError as exc:
99
+ raise ApiError(0, f"network error: {exc}") from exc
100
+ if response.status_code != 200:
101
+ raise ApiError(response.status_code, _extract_detail(response))
102
+ return response.json() # type: ignore[no-any-return]
103
+
104
+
105
+ def _extract_detail(response: httpx.Response) -> str:
106
+ try:
107
+ body = response.json()
108
+ except ValueError:
109
+ body = None
110
+ if isinstance(body, dict):
111
+ detail = body.get("detail")
112
+ if isinstance(detail, str):
113
+ return detail
114
+ return response.text[:200] or response.reason_phrase
@@ -0,0 +1,118 @@
1
+ """Read/write the CLI credentials file.
2
+
3
+ Stored at ``~/.config/slopguard/credentials.toml`` with mode 0600 so other
4
+ users on the machine cannot read it (spec §6.6).
5
+
6
+ Layout::
7
+
8
+ [active]
9
+ api_url = "https://api.slopguard.io"
10
+ token = "sg_live_..."
11
+ org_slug = "acme-security"
12
+ organization_id = "..."
13
+ user_email = "harry@unknownslab.com"
14
+
15
+ A future ``slopguard switch`` could let multiple profiles live side-by-
16
+ side; for now only ``[active]`` is read or written.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ import tomllib
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+
26
+ DEFAULT_CREDENTIALS_PATH = Path.home() / ".config" / "slopguard" / "credentials.toml"
27
+
28
+
29
+ class CredentialsError(Exception):
30
+ """Raised when credentials cannot be read or written."""
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Credentials:
35
+ api_url: str
36
+ token: str
37
+ org_slug: str | None = None
38
+ organization_id: str | None = None
39
+ user_email: str | None = None
40
+
41
+
42
+ def load_credentials(path: Path | None = None) -> Credentials | None:
43
+ """Return the active credentials, or None if the file doesn't exist."""
44
+ if path is None:
45
+ path = DEFAULT_CREDENTIALS_PATH
46
+ if not path.exists():
47
+ return None
48
+ try:
49
+ with path.open("rb") as fh:
50
+ data = tomllib.load(fh)
51
+ except tomllib.TOMLDecodeError as exc:
52
+ raise CredentialsError(f"invalid credentials file {path}: {exc}") from exc
53
+
54
+ active = data.get("active")
55
+ if not isinstance(active, dict):
56
+ return None
57
+ token = active.get("token")
58
+ api_url = active.get("api_url")
59
+ if not isinstance(token, str) or not isinstance(api_url, str):
60
+ return None
61
+ return Credentials(
62
+ api_url=api_url,
63
+ token=token,
64
+ org_slug=active.get("org_slug") if isinstance(active.get("org_slug"), str) else None,
65
+ organization_id=(
66
+ active.get("organization_id")
67
+ if isinstance(active.get("organization_id"), str)
68
+ else None
69
+ ),
70
+ user_email=(
71
+ active.get("user_email") if isinstance(active.get("user_email"), str) else None
72
+ ),
73
+ )
74
+
75
+
76
+ def save_credentials(creds: Credentials, path: Path | None = None) -> None:
77
+ """Write ``creds`` to the file at ``path``. Creates parent dirs and chmod 0600."""
78
+ if path is None:
79
+ path = DEFAULT_CREDENTIALS_PATH
80
+ path.parent.mkdir(parents=True, exist_ok=True)
81
+ body = _render(creds)
82
+ # Write to a temp sibling then atomic-rename so a crash mid-write can't
83
+ # leave a half-written credentials file.
84
+ tmp = path.with_suffix(path.suffix + ".tmp")
85
+ tmp.write_text(body, encoding="utf-8")
86
+ os.chmod(tmp, 0o600)
87
+ tmp.replace(path)
88
+
89
+
90
+ def delete_credentials(path: Path | None = None) -> bool:
91
+ """Remove the credentials file. Returns True if a file was deleted."""
92
+ if path is None:
93
+ path = DEFAULT_CREDENTIALS_PATH
94
+ if not path.exists():
95
+ return False
96
+ path.unlink()
97
+ return True
98
+
99
+
100
+ def _render(creds: Credentials) -> str:
101
+ lines = [
102
+ "# slopguard CLI credentials — do not share. chmod 600.",
103
+ "",
104
+ "[active]",
105
+ f'api_url = "{_escape(creds.api_url)}"',
106
+ f'token = "{_escape(creds.token)}"',
107
+ ]
108
+ if creds.org_slug:
109
+ lines.append(f'org_slug = "{_escape(creds.org_slug)}"')
110
+ if creds.organization_id:
111
+ lines.append(f'organization_id = "{_escape(creds.organization_id)}"')
112
+ if creds.user_email:
113
+ lines.append(f'user_email = "{_escape(creds.user_email)}"')
114
+ return "\n".join(lines) + "\n"
115
+
116
+
117
+ def _escape(value: str) -> str:
118
+ return value.replace("\\", "\\\\").replace('"', '\\"')
@@ -0,0 +1,94 @@
1
+ """``slopguard update`` — refresh the local hallucination DB.
2
+
3
+ Fetches the curated DB from the SlopGuard GitHub Pages site (no API,
4
+ no account, no auth) and writes a seed-shaped file to
5
+ ``~/.cache/slopguard/hallucinations_db.json``. The next ``slopguard
6
+ scan`` prefers this cached file over the bundled seed (see
7
+ ``slopguard.data.load_hallucination_db``).
8
+
9
+ Source of truth:
10
+ https://hariomunknownslab.github.io/slopguard/db.json
11
+
12
+ Updated daily by the probe-cron GitHub Action; each entry goes through
13
+ PR review before publication (see probe-data/README.md in the repo).
14
+
15
+ Override the URL with ``SLOPGUARD_DB_URL`` to fetch from a fork or
16
+ mirror. The legacy ``SLOPGUARD_API_URL`` env var still works for the
17
+ 0.1.x SaaS endpoint shape — used as a fallback for backward
18
+ compatibility.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import os
25
+ from datetime import UTC, datetime
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ import httpx
30
+
31
+ DEFAULT_DB_URL = "https://hariomunknownslab.github.io/slopguard/db.json"
32
+ CACHE_PATH = Path.home() / ".cache" / "slopguard" / "hallucinations_db.json"
33
+
34
+
35
+ def _db_url() -> str:
36
+ """Where to fetch the published DB.
37
+
38
+ Precedence: ``SLOPGUARD_DB_URL`` > legacy
39
+ ``SLOPGUARD_API_URL/v1/hallucinations`` > default GitHub Pages.
40
+ """
41
+ if explicit := os.environ.get("SLOPGUARD_DB_URL"):
42
+ return explicit
43
+ if legacy := os.environ.get("SLOPGUARD_API_URL"):
44
+ return legacy.rstrip("/") + "/v1/hallucinations"
45
+ return DEFAULT_DB_URL
46
+
47
+
48
+ def _convert(wire: dict[str, Any]) -> dict[str, Any]:
49
+ """Map the API wire payload to the local seed schema."""
50
+ entries: list[dict[str, Any]] = []
51
+ for row in wire.get("entries", []):
52
+ entries.append(
53
+ {
54
+ "name": row["package_name"],
55
+ "ecosystem": row["ecosystem"],
56
+ "first_seen": row["first_observed_at"][:10],
57
+ "recurrence_rate": row["recurrence_rate"],
58
+ "models_observed": [],
59
+ "notes": (
60
+ f"Promoted from SlopGuard probe — {row['total_observations']}x "
61
+ f"observations, published {row['published_at'][:10]}"
62
+ ),
63
+ }
64
+ )
65
+ return {
66
+ "schema_version": 1,
67
+ "updated": wire.get("generated_at", datetime.now(tz=UTC).isoformat())[:10],
68
+ "entries": entries,
69
+ }
70
+
71
+
72
+ def run() -> int:
73
+ url = _db_url()
74
+ try:
75
+ resp = httpx.get(url, timeout=15.0)
76
+ resp.raise_for_status()
77
+ except httpx.HTTPError as exc:
78
+ print(f"slopguard update: failed to reach {url}: {exc}")
79
+ return 1
80
+
81
+ try:
82
+ payload = _convert(resp.json())
83
+ except (ValueError, KeyError) as exc:
84
+ print(f"slopguard update: invalid response payload ({exc})")
85
+ return 1
86
+
87
+ CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
88
+ tmp = CACHE_PATH.with_suffix(".json.tmp")
89
+ tmp.write_text(json.dumps(payload, indent=2), encoding="utf-8")
90
+ tmp.replace(CACHE_PATH)
91
+
92
+ count = len(payload["entries"])
93
+ print(f"slopguard update: cached {count} entries -> {CACHE_PATH}")
94
+ return 0
@@ -107,7 +107,7 @@ def runner() -> CliRunner:
107
107
  def test_version_subcommand(runner: CliRunner) -> None:
108
108
  result = runner.invoke(app, ["version"])
109
109
  assert result.exit_code == 0
110
- assert result.stdout.strip() == "0.1.1"
110
+ assert result.stdout.strip() == "0.2.0"
111
111
 
112
112
 
113
113
  def test_scan_fixtures_no_network_terminal(runner: CliRunner, fixtures_dir: Path) -> None:
@@ -183,4 +183,4 @@ def test_module_main_runs() -> None:
183
183
  check=False,
184
184
  )
185
185
  assert result.returncode == 0
186
- assert result.stdout.strip() == "0.1.1"
186
+ assert result.stdout.strip() == "0.2.0"
@@ -32,10 +32,22 @@ def test_main_entry_invokes_typer(monkeypatch: pytest.MonkeyPatch) -> None:
32
32
  assert exc_info.value.code == 0
33
33
 
34
34
 
35
- def test_update_stub_runs() -> None:
36
- from slopguard.update import run
37
-
38
- assert run() == 0
35
+ @respx.mock
36
+ def test_update_writes_cache_when_api_reachable(
37
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
38
+ ) -> None:
39
+ """Smoke test: with a mocked API the update path returns 0 and writes a cache."""
40
+ from slopguard import update
41
+
42
+ monkeypatch.setattr(update, "CACHE_PATH", tmp_path / "h.json")
43
+ monkeypatch.setenv("SLOPGUARD_API_URL", "http://api.test")
44
+ respx.get("http://api.test/v1/hallucinations").mock(
45
+ return_value=httpx.Response(
46
+ 200,
47
+ json={"generated_at": "2026-05-23T22:00:00Z", "count": 0, "entries": []},
48
+ )
49
+ )
50
+ assert update.run() == 0
39
51
 
40
52
 
41
53
  def test_npm_parser_lockfile_v1(tmp_path: Path) -> None: