ghost-api-cli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. ghost_api/__init__.py +3 -0
  2. ghost_api/__main__.py +5 -0
  3. ghost_api/adopt.py +173 -0
  4. ghost_api/agent/__init__.py +0 -0
  5. ghost_api/agent/orchestrator.py +413 -0
  6. ghost_api/agent/prompts.py +149 -0
  7. ghost_api/agent/tools.py +84 -0
  8. ghost_api/analysis/__init__.py +0 -0
  9. ghost_api/analysis/analyzer.py +130 -0
  10. ghost_api/analysis/detectors.py +188 -0
  11. ghost_api/analysis/exposure.py +80 -0
  12. ghost_api/analysis/graphql.py +67 -0
  13. ghost_api/analysis/models.py +64 -0
  14. ghost_api/analysis/schema.py +109 -0
  15. ghost_api/analysis/url_grouping.py +217 -0
  16. ghost_api/api.py +264 -0
  17. ghost_api/audit.py +78 -0
  18. ghost_api/browser/__init__.py +0 -0
  19. ghost_api/browser/auth_state.py +172 -0
  20. ghost_api/browser/driver.py +273 -0
  21. ghost_api/browser/egress.py +183 -0
  22. ghost_api/browser/gate.py +127 -0
  23. ghost_api/browser/install.py +28 -0
  24. ghost_api/browser/snapshot.py +221 -0
  25. ghost_api/capture/__init__.py +0 -0
  26. ghost_api/capture/filters.py +255 -0
  27. ghost_api/capture/har.py +148 -0
  28. ghost_api/capture/models.py +41 -0
  29. ghost_api/capture/personal.py +188 -0
  30. ghost_api/capture/recorder.py +181 -0
  31. ghost_api/cli/__init__.py +44 -0
  32. ghost_api/cli/_common.py +219 -0
  33. ghost_api/cli/client.py +487 -0
  34. ghost_api/cli/data.py +492 -0
  35. ghost_api/cli/explore.py +379 -0
  36. ghost_api/cli/manage.py +437 -0
  37. ghost_api/config.py +132 -0
  38. ghost_api/drift.py +344 -0
  39. ghost_api/drive.py +211 -0
  40. ghost_api/errors.py +106 -0
  41. ghost_api/events.py +213 -0
  42. ghost_api/exitcodes.py +21 -0
  43. ghost_api/export/__init__.py +71 -0
  44. ghost_api/export/confidence.py +45 -0
  45. ghost_api/export/html.py +41 -0
  46. ghost_api/export/openapi.py +201 -0
  47. ghost_api/export/report.py +227 -0
  48. ghost_api/export/site_data.py +196 -0
  49. ghost_api/export/tabular.py +88 -0
  50. ghost_api/export/viewer/report.html +286 -0
  51. ghost_api/generator/__init__.py +221 -0
  52. ghost_api/generator/cassettes.py +80 -0
  53. ghost_api/generator/models_gen.py +144 -0
  54. ghost_api/generator/naming.py +174 -0
  55. ghost_api/generator/package.py +197 -0
  56. ghost_api/generator/python_gen.py +172 -0
  57. ghost_api/generator/spec.py +486 -0
  58. ghost_api/generator/templates/client.py.j2 +518 -0
  59. ghost_api/generator/templates/client.test.ts.j2 +106 -0
  60. ghost_api/generator/templates/client.ts.j2 +339 -0
  61. ghost_api/generator/templates/models.py.j2 +45 -0
  62. ghost_api/generator/templates/package_readme.md.j2 +111 -0
  63. ghost_api/generator/templates/pyproject.toml.j2 +22 -0
  64. ghost_api/generator/templates/test_client.py.j2 +105 -0
  65. ghost_api/generator/ts_gen.py +232 -0
  66. ghost_api/integrations/__init__.py +0 -0
  67. ghost_api/integrations/claude_code/commands/ghost-data.md +10 -0
  68. ghost_api/integrations/claude_code/commands/ghost-diff.md +10 -0
  69. ghost_api/integrations/claude_code/commands/ghost-explore.md +10 -0
  70. ghost_api/integrations/claude_code/skills/ghost-api/SKILL.md +46 -0
  71. ghost_api/integrations/setup.py +84 -0
  72. ghost_api/llm/__init__.py +0 -0
  73. ghost_api/llm/doctor.py +60 -0
  74. ghost_api/llm/gateway.py +476 -0
  75. ghost_api/llm/providers.py +124 -0
  76. ghost_api/memory/__init__.py +0 -0
  77. ghost_api/memory/knowledge.py +157 -0
  78. ghost_api/memory/store.py +883 -0
  79. ghost_api/net.py +235 -0
  80. ghost_api/netguard.py +203 -0
  81. ghost_api/pipeline.py +745 -0
  82. ghost_api/probe/__init__.py +0 -0
  83. ghost_api/probe/prober.py +311 -0
  84. ghost_api/probe/replay.py +313 -0
  85. ghost_api/project.py +186 -0
  86. ghost_api/py.typed +0 -0
  87. ghost_api/runtime.py +397 -0
  88. ghost_api/serve/__init__.py +101 -0
  89. ghost_api/serve/explorer_mcp.py +305 -0
  90. ghost_api/serve/mcp.py +121 -0
  91. ghost_api/serve/mock.py +130 -0
  92. ghost_api/serve/rest.py +132 -0
  93. ghost_api/site.py +84 -0
  94. ghost_api/specdiff.py +122 -0
  95. ghost_api/ui/__init__.py +0 -0
  96. ghost_api/ui/keys.py +134 -0
  97. ghost_api/ui/repl.py +453 -0
  98. ghost_api/ui/terminal.py +573 -0
  99. ghost_api/verify/__init__.py +0 -0
  100. ghost_api/verify/classify.py +84 -0
  101. ghost_api/verify/repair.py +40 -0
  102. ghost_api/verify/runner.py +110 -0
  103. ghost_api/verify/verifier.py +291 -0
  104. ghost_api/watch.py +313 -0
  105. ghost_api/web/__init__.py +1 -0
  106. ghost_api/web/server.py +690 -0
  107. ghost_api/web/static/assets/Endpoints-zPnjlcM8.js +46 -0
  108. ghost_api/web/static/assets/Run-D85zWHey.js +91 -0
  109. ghost_api/web/static/assets/Settings-D0aWHFAt.js +11 -0
  110. ghost_api/web/static/assets/Site-a88shEmP.js +71 -0
  111. ghost_api/web/static/assets/Watches-CqZWIa9G.js +16 -0
  112. ghost_api/web/static/assets/circle-x-BcTE_1tr.js +11 -0
  113. ghost_api/web/static/assets/index-Ds2e5B9f.js +141 -0
  114. ghost_api/web/static/assets/index-xQpyIBbG.css +1 -0
  115. ghost_api/web/static/assets/minus-C2xqP1eQ.js +6 -0
  116. ghost_api/web/static/assets/refresh-cw-CxdR_8Oi.js +6 -0
  117. ghost_api/web/static/favicon.svg +1 -0
  118. ghost_api/web/static/index.html +26 -0
  119. ghost_api/workflows.py +203 -0
  120. ghost_api_cli-0.2.0.dist-info/METADATA +1019 -0
  121. ghost_api_cli-0.2.0.dist-info/RECORD +125 -0
  122. ghost_api_cli-0.2.0.dist-info/WHEEL +5 -0
  123. ghost_api_cli-0.2.0.dist-info/entry_points.txt +2 -0
  124. ghost_api_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
  125. ghost_api_cli-0.2.0.dist-info/top_level.txt +1 -0
ghost_api/api.py ADDED
@@ -0,0 +1,264 @@
1
+ """Ghost API from Python: the same things the command line does, as functions.
2
+
3
+ from ghost_api.api import discover, fetch, diff
4
+
5
+ result = discover("https://quotes.toscrape.com/scroll", "list quotes with their authors")
6
+ print(result.status, [e["path"] for e in result.endpoints], result.client_files)
7
+
8
+ rows = fetch("quotes.toscrape.com/scroll", "get_quotes", pages=3)
9
+ changes = diff("quotes.toscrape.com/scroll", live=True)
10
+
11
+ Every function respects the same rules as the CLI (robots.txt, rate limits, the network
12
+ boundary, personal data hidden) and uses the project's settings (ghost-api.toml) unless
13
+ `db` / `out` are given. Problems are exceptions: `NotAllowed`, `NoLLMKey`,
14
+ `NothingRemembered`, `GhostApiError`.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import Callable
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from ghost_api import drift, workflows
25
+ from ghost_api.config import Settings, default_db_path, load_env
26
+ from ghost_api.events import Event, EventBus
27
+ from ghost_api.generator import NothingToGenerate, generate_client, site_spec
28
+ from ghost_api.memory.store import Store
29
+ from ghost_api.pipeline import RunOptions
30
+ from ghost_api.runtime import OperationError, SiteRuntime
31
+ from ghost_api.site import SiteAmbiguous, resolve_site
32
+ from ghost_api.workflows import NoLLMKey, NotAllowed, NothingRemembered
33
+
34
+ __all__ = [
35
+ "GhostApiError",
36
+ "NoLLMKey",
37
+ "NotAllowed",
38
+ "NothingRemembered",
39
+ "RunResult",
40
+ "diff",
41
+ "discover",
42
+ "fetch",
43
+ "generate",
44
+ "operations",
45
+ "refresh",
46
+ "sites",
47
+ ]
48
+
49
+
50
+ class GhostApiError(Exception):
51
+ """Something the caller can fix: an unknown site, nothing stored yet, a bad input."""
52
+
53
+
54
+ @dataclass
55
+ class RunResult:
56
+ status: str # goal-covered, finished, captured, bot-protection, interrupted, failed...
57
+ reason: str
58
+ site: str
59
+ run_id: int | None
60
+ endpoints: list[dict[str, Any]] # one summary per endpoint (path, status, params...)
61
+ client: dict[str, Any] | None # class name, directory, files, methods
62
+ report: str
63
+ report_html: str
64
+ openapi: str
65
+ network: dict[str, Any] = field(default_factory=dict) # the run's permissions summary
66
+ raw: dict[str, Any] = field(default_factory=dict) # everything, as `--json` prints it
67
+
68
+ @property
69
+ def ok(self) -> bool:
70
+ return self.status not in ("failed", "llm-unavailable", "bot-protection", "interrupted")
71
+
72
+ @property
73
+ def client_files(self) -> list[str]:
74
+ if not self.client:
75
+ return []
76
+ return [str(Path(self.client["directory"]) / f) for f in self.client.get("files", [])]
77
+
78
+
79
+ def _result(data: dict[str, Any]) -> RunResult:
80
+ return RunResult(
81
+ status=str(data.get("status", "failed")),
82
+ reason=str(data.get("reason", "")),
83
+ site=str(data.get("site", "")),
84
+ run_id=data.get("run_id"),
85
+ endpoints=list(data.get("endpoints", [])),
86
+ client=data.get("client"),
87
+ report=str(data.get("report", "")),
88
+ report_html=str(data.get("report_html", "")),
89
+ openapi=str(data.get("openapi", "")),
90
+ network=dict(data.get("network", {})),
91
+ raw=data,
92
+ )
93
+
94
+
95
+ def _bus(on_event: Callable[[Event], None] | None) -> tuple[EventBus, workflows.RunCollector]:
96
+ load_env()
97
+ bus = EventBus()
98
+ collector = workflows.RunCollector()
99
+ bus.subscribe(collector)
100
+ if on_event is not None:
101
+ bus.subscribe(on_event)
102
+ return bus, collector
103
+
104
+
105
+ def _db(db: Path | str | None) -> Path:
106
+ return Path(db) if db is not None else default_db_path()
107
+
108
+
109
+ def _site(store: Store, name: str) -> str:
110
+ try:
111
+ return resolve_site(name, {r.domain for r in store.list_runs()})
112
+ except SiteAmbiguous as exc:
113
+ raise GhostApiError(str(exc)) from exc
114
+
115
+
116
+ def discover(
117
+ url: str,
118
+ goal: str,
119
+ *,
120
+ use_agent: bool = True,
121
+ lang: str = "python",
122
+ out: Path | str = "output",
123
+ db: Path | str | None = None,
124
+ headed: bool = False,
125
+ max_steps: int = 40,
126
+ max_llm_calls: int = 50,
127
+ max_minutes: float = 15.0,
128
+ rate_limit: float = 1.0,
129
+ verify: bool = True,
130
+ probe: bool = True,
131
+ on_event: Callable[[Event], None] | None = None,
132
+ ) -> RunResult:
133
+ """Explore a site (with the AI agent unless `use_agent=False`), test what it found,
134
+ and write a client and report. `on_event` receives every live event."""
135
+ settings = Settings(
136
+ url=url, goal=goal, lang=lang, headed=headed, max_steps=max_steps,
137
+ max_llm_calls=max_llm_calls, max_minutes=max_minutes, rate_limit=rate_limit,
138
+ db_path=_db(db),
139
+ ) # fmt: skip
140
+ opts = RunOptions(lang=lang, headed=headed, max_steps=max_steps, max_minutes=max_minutes,
141
+ no_probe=not probe, no_verify=not verify, out=Path(out)) # fmt: skip
142
+ bus, collector = _bus(on_event)
143
+ workflows.run(settings, opts, bus, use_agent=use_agent)
144
+ return _result(collector.data)
145
+
146
+
147
+ def refresh(
148
+ site: str,
149
+ *,
150
+ use_llm: bool = True,
151
+ lang: str = "python",
152
+ out: Path | str = "output",
153
+ db: Path | str | None = None,
154
+ verify: bool = True,
155
+ on_event: Callable[[Event], None] | None = None,
156
+ ) -> RunResult:
157
+ """Re-check a known site from memory: replays first, the browser only if needed."""
158
+ path = _db(db)
159
+ with Store(path) as store:
160
+ name = _site(store, site)
161
+ opts = RunOptions(lang=lang, max_steps=10, no_verify=not verify, out=Path(out))
162
+ bus, collector = _bus(on_event)
163
+ workflows.refresh(path, name, opts, bus, use_llm=use_llm)
164
+ return _result(collector.data)
165
+
166
+
167
+ def generate(
168
+ site: str, *, lang: str = "python", out: Path | str = "output", db: Path | str | None = None
169
+ ) -> dict[str, Any]:
170
+ """(Re)write a site's client from stored data (no browser, no LLM). Returns the client's
171
+ class name, files and version."""
172
+ with Store(_db(db)) as store:
173
+ name = _site(store, site)
174
+ try:
175
+ result = generate_client(store, name, Path(out), lang=lang)
176
+ except NothingToGenerate as exc:
177
+ raise GhostApiError(str(exc)) from exc
178
+ client = result.client
179
+ return {
180
+ "class_name": result.spec.class_name,
181
+ "directory": str(client.directory),
182
+ "files": [str(p) for p in (client.client_path, client.models_path, client.tests_path)],
183
+ "version": result.version.version if result.version else result.spec.version,
184
+ }
185
+
186
+
187
+ def operations(site: str, *, db: Path | str | None = None) -> list[dict[str, Any]]:
188
+ """What can be fetched from a site: name, summary, endpoint, inputs (with examples)."""
189
+ with Store(_db(db)) as store:
190
+ name = _site(store, site)
191
+ try:
192
+ runtime = SiteRuntime(store, name)
193
+ except OperationError as exc:
194
+ raise GhostApiError(str(exc)) from exc
195
+ with runtime:
196
+ return [
197
+ {"name": op.name, "summary": op.summary, "endpoint": op.label,
198
+ "inputs": {i.name: i.example for i in op.inputs}, "pagination": op.pagination}
199
+ for op in runtime.operations()
200
+ ] # fmt: skip
201
+
202
+
203
+ def fetch(
204
+ site: str,
205
+ operation: str,
206
+ inputs: dict[str, str] | None = None,
207
+ *,
208
+ pages: int = 5,
209
+ max_rows: int = 1000,
210
+ db: Path | str | None = None,
211
+ ) -> list[Any]:
212
+ """Rows from one operation, following pages. Read-only, rate-limited, personal data
213
+ hidden (like `ghost-api fetch`)."""
214
+ with Store(_db(db)) as store:
215
+ name = _site(store, site)
216
+ try:
217
+ runtime = SiteRuntime(store, name)
218
+ except OperationError as exc:
219
+ raise GhostApiError(str(exc)) from exc
220
+ rows: list[Any] = []
221
+ with runtime:
222
+ try:
223
+ op = runtime.operation(operation)
224
+ for page in runtime.iterate(op, inputs or {}, pages, max_rows):
225
+ if not page.result.ok:
226
+ detail = page.result.error or f"HTTP {page.result.status}"
227
+ if not rows:
228
+ raise GhostApiError(f"{operation} failed: {detail}")
229
+ break
230
+ rows += page.records
231
+ except OperationError as exc:
232
+ raise GhostApiError(str(exc)) from exc
233
+ return rows
234
+
235
+
236
+ def diff(site: str, *, live: bool = False, db: Path | str | None = None) -> dict[str, Any]:
237
+ """What changed in a site's API since the last client version: version, previous and
238
+ a list of changes ({"severity", "where", "text"})."""
239
+ with Store(_db(db)) as store:
240
+ name = _site(store, site)
241
+ try:
242
+ spec = site_spec(store, name)
243
+ except NothingToGenerate as exc:
244
+ raise GhostApiError(str(exc)) from exc
245
+ info = drift.version_for(store, name, spec)
246
+ changes = list(info.changes)
247
+ if live:
248
+ changes += drift.live_check(store, name)
249
+ return {
250
+ "site": name,
251
+ "version": info.version,
252
+ "previous": info.previous,
253
+ "changes": drift.changes_json(changes),
254
+ "breaking": sum(c.severity == "breaking" for c in changes),
255
+ }
256
+
257
+
258
+ def sites(*, db: Path | str | None = None) -> list[str]:
259
+ """Sites explored so far, newest first."""
260
+ path = _db(db)
261
+ if not path.exists():
262
+ return []
263
+ with Store(path) as store:
264
+ return list(dict.fromkeys(r.domain for r in store.list_runs()))
ghost_api/audit.py ADDED
@@ -0,0 +1,78 @@
1
+ """Audit log: every request Ghost API sent itself, and every request its rules blocked.
2
+
3
+ One JSON line per event, appended to `ghost_api_files/audit/<site>.jsonl` next to the
4
+ database, so a team can answer "what did this tool send to that site, and when?".
5
+ Only the method, host, path, the names of query parameters, the status and the decision
6
+ are kept: never query values, bodies, headers or cookies.
7
+
8
+ {"at": "2026-10-17T09:12:03+00:00", "site": "example.com", "run": 12, "kind": "direct",
9
+ "method": "GET", "host": "example.com", "path": "/api/items", "query": ["page"],
10
+ "status": 200, "decision": "sent"}
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import threading
17
+ from datetime import UTC, datetime
18
+ from pathlib import Path
19
+ from typing import Any
20
+ from urllib.parse import parse_qsl, urlsplit
21
+
22
+ from ghost_api.site import to_url_id
23
+
24
+ _lock = threading.Lock()
25
+
26
+
27
+ def audit_file(db_path: Path, site: str) -> Path:
28
+ return db_path.parent / f"{db_path.stem}_files" / "audit" / f"{to_url_id(site)}.jsonl"
29
+
30
+
31
+ class AuditLog:
32
+ def __init__(self, db_path: Path, site: str, run_id: int | None = None) -> None:
33
+ self.path = audit_file(db_path, site)
34
+ self.site = site
35
+ self.run_id = run_id
36
+
37
+ def record(self, kind: str, method: str, url: str, status: int | None, decision: str) -> None:
38
+ parts = urlsplit(url)
39
+ entry: dict[str, Any] = {
40
+ "at": datetime.now(UTC).isoformat(timespec="seconds"),
41
+ "site": self.site,
42
+ "run": self.run_id,
43
+ "kind": kind, # direct (endpoint tests, fetch, watch, serve) or blocked
44
+ "method": method.upper(),
45
+ "host": (parts.hostname or "").lower(),
46
+ "path": parts.path or "/",
47
+ "query": sorted({k for k, _ in parse_qsl(parts.query, keep_blank_values=True)}),
48
+ "status": status,
49
+ "decision": decision,
50
+ }
51
+ line = json.dumps(entry, ensure_ascii=False) + "\n"
52
+ with _lock:
53
+ self.path.parent.mkdir(parents=True, exist_ok=True)
54
+ with self.path.open("a", encoding="utf-8") as handle:
55
+ handle.write(line)
56
+
57
+ def blocked(self, message: str) -> None:
58
+ """A rule stopped a request; the message names the host and path."""
59
+ entry = {
60
+ "at": datetime.now(UTC).isoformat(timespec="seconds"),
61
+ "site": self.site,
62
+ "run": self.run_id,
63
+ "kind": "blocked",
64
+ "decision": message[:300],
65
+ }
66
+ with _lock:
67
+ self.path.parent.mkdir(parents=True, exist_ok=True)
68
+ with self.path.open("a", encoding="utf-8") as handle:
69
+ handle.write(json.dumps(entry, ensure_ascii=False) + "\n")
70
+
71
+
72
+ def read(db_path: Path, site: str, limit: int | None = None) -> list[dict[str, Any]]:
73
+ path = audit_file(db_path, site)
74
+ if not path.exists():
75
+ return []
76
+ lines = path.read_text(encoding="utf-8").splitlines()
77
+ entries = [json.loads(line) for line in lines if line.strip()]
78
+ return entries[-limit:] if limit else entries
File without changes
@@ -0,0 +1,172 @@
1
+ """Saved logins: the browser session (cookies and site storage) after you log in yourself.
2
+
3
+ `ghost-api login URL` opens a visible browser; you log in by hand (Ghost API never sees or
4
+ stores a password); the session is then saved per site. Later runs and refreshes open the
5
+ browser already logged in, and fetch / watch / serve add the session's cookies to their
6
+ direct calls. `ghost-api logout SITE` (or `forget`) deletes it.
7
+
8
+ At rest:
9
+ - Windows: encrypted with DPAPI for your Windows account, so another account, or a copy
10
+ of the file on another machine, can't read it.
11
+ - Elsewhere: a file only your user can read (mode 600), not encrypted. Use disk
12
+ encryption, or log out when you are done.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import sys
20
+ import time
21
+ from datetime import UTC, datetime
22
+ from pathlib import Path
23
+ from typing import Any
24
+ from urllib.parse import urlsplit
25
+
26
+ from ghost_api.site import to_url_id
27
+
28
+ MAGIC_DPAPI = b"GHOSTAPI-DPAPI1\n"
29
+ MAGIC_PLAIN = b"GHOSTAPI-PLAIN1\n"
30
+
31
+
32
+ class LoginError(Exception):
33
+ """A saved login can't be read (damaged, or made by another Windows account)."""
34
+
35
+
36
+ def logins_dir(db_path: Path) -> Path:
37
+ """Next to the database, like screenshots: never in the output folder."""
38
+ return db_path.parent / f"{db_path.stem}_files" / "logins"
39
+
40
+
41
+ def _file(db_path: Path, site: str) -> Path:
42
+ return logins_dir(db_path) / f"{to_url_id(site)}.session"
43
+
44
+
45
+ def save(db_path: Path, site: str, state: dict[str, Any]) -> dict[str, Any]:
46
+ """Save a Playwright storage state; returns what was saved (no values)."""
47
+ meta = {
48
+ "site": site,
49
+ "saved_at": datetime.now(UTC).isoformat(timespec="seconds"),
50
+ "cookies": len(state.get("cookies", [])),
51
+ "domains": sorted({str(c.get("domain", "")).lstrip(".") for c in state.get("cookies", [])}),
52
+ "encrypted": sys.platform == "win32",
53
+ }
54
+ payload = json.dumps({"meta": meta, "state": state}).encode("utf-8")
55
+ path = _file(db_path, site)
56
+ path.parent.mkdir(parents=True, exist_ok=True)
57
+ data = MAGIC_DPAPI + _protect(payload) if meta["encrypted"] else MAGIC_PLAIN + payload
58
+ # Created with owner-only permissions before any secret is written.
59
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
60
+ with os.fdopen(fd, "wb") as handle:
61
+ handle.write(data)
62
+ return meta
63
+
64
+
65
+ def load(db_path: Path, site: str) -> tuple[dict[str, Any], dict[str, Any]] | None:
66
+ """(state, meta) for a site's saved login, or None when there is none."""
67
+ path = _file(db_path, site)
68
+ if not path.exists():
69
+ return None
70
+ data = path.read_bytes()
71
+ try:
72
+ if data.startswith(MAGIC_DPAPI):
73
+ payload = _unprotect(data[len(MAGIC_DPAPI) :])
74
+ elif data.startswith(MAGIC_PLAIN):
75
+ payload = data[len(MAGIC_PLAIN) :]
76
+ else:
77
+ raise LoginError("unknown format")
78
+ saved = json.loads(payload)
79
+ return saved["state"], saved["meta"]
80
+ except (OSError, ValueError, KeyError) as exc:
81
+ raise LoginError(
82
+ f"The saved login for {site} can't be read ({exc}). Run `ghost-api login` again."
83
+ ) from exc
84
+
85
+
86
+ def delete(db_path: Path, site: str) -> bool:
87
+ path = _file(db_path, site)
88
+ if path.exists():
89
+ path.unlink()
90
+ return True
91
+ return False
92
+
93
+
94
+ def saved_sites(db_path: Path) -> list[dict[str, Any]]:
95
+ out = []
96
+ for path in sorted(logins_dir(db_path).glob("*.session")):
97
+ try:
98
+ data = path.read_bytes()
99
+ if data.startswith(MAGIC_DPAPI):
100
+ data = MAGIC_PLAIN + _unprotect(data[len(MAGIC_DPAPI) :])
101
+ out.append(json.loads(data[len(MAGIC_PLAIN) :])["meta"])
102
+ except (OSError, ValueError, KeyError):
103
+ out.append({"site": path.stem, "saved_at": "?", "damaged": True})
104
+ return out
105
+
106
+
107
+ def cookie_header(state: dict[str, Any], url: str, now: float | None = None) -> str:
108
+ """The Cookie header a browser would send to `url` with this session."""
109
+ parts = urlsplit(url)
110
+ host, path = (parts.hostname or "").lower(), parts.path or "/"
111
+ now = time.time() if now is None else now
112
+ pairs = []
113
+ for cookie in state.get("cookies", []):
114
+ domain = str(cookie.get("domain", "")).lower()
115
+ bare = domain.lstrip(".")
116
+ if not (host == bare or host.endswith("." + bare)):
117
+ continue
118
+ if not path.startswith(str(cookie.get("path") or "/")):
119
+ continue
120
+ if cookie.get("secure") and parts.scheme != "https":
121
+ continue
122
+ expires = cookie.get("expires", -1)
123
+ if isinstance(expires, int | float) and 0 < expires < now:
124
+ continue
125
+ pairs.append(f"{cookie['name']}={cookie['value']}")
126
+ return "; ".join(pairs)
127
+
128
+
129
+ def age_days(meta: dict[str, Any]) -> float:
130
+ try:
131
+ saved = datetime.fromisoformat(str(meta["saved_at"]))
132
+ except (KeyError, ValueError):
133
+ return 0.0
134
+ return (datetime.now(UTC) - saved).total_seconds() / 86400
135
+
136
+
137
+ # --- Windows DPAPI (crypt32), through ctypes: no extra dependency ---------------------------
138
+
139
+
140
+ def _protect(data: bytes) -> bytes:
141
+ return _dpapi(data, protect=True)
142
+
143
+
144
+ def _unprotect(data: bytes) -> bytes:
145
+ if sys.platform != "win32":
146
+ raise LoginError("this login was saved on Windows and can only be read there")
147
+ return _dpapi(data, protect=False)
148
+
149
+
150
+ def _dpapi(data: bytes, protect: bool) -> bytes:
151
+ if sys.platform != "win32": # also tells the type checker windll exists below
152
+ raise LoginError("Windows data protection is only available on Windows")
153
+ import ctypes
154
+ from ctypes import wintypes
155
+
156
+ class Blob(ctypes.Structure):
157
+ _fields_ = [("cbData", wintypes.DWORD), ("pbData", ctypes.POINTER(ctypes.c_char))]
158
+
159
+ buffer = ctypes.create_string_buffer(data, len(data))
160
+ blob_in = Blob(len(data), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_char)))
161
+ blob_out = Blob()
162
+ crypt32 = ctypes.windll.crypt32
163
+ kernel32 = ctypes.windll.kernel32
164
+ call = crypt32.CryptProtectData if protect else crypt32.CryptUnprotectData
165
+ # CRYPTPROTECT_UI_FORBIDDEN = 0x1: never show a prompt.
166
+ ok = call(ctypes.byref(blob_in), None, None, None, None, 0x1, ctypes.byref(blob_out))
167
+ if not ok:
168
+ raise LoginError(f"Windows could not {'protect' if protect else 'unlock'} the login")
169
+ try:
170
+ return ctypes.string_at(blob_out.pbData, blob_out.cbData)
171
+ finally:
172
+ kernel32.LocalFree(blob_out.pbData)