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/drift.py ADDED
@@ -0,0 +1,344 @@
1
+ """API drift: what changed in a site's API, how much it matters, and the client's version.
2
+
3
+ A *snapshot* is what a client user sees: each method (its call, parameters and return
4
+ type) and each model (its fields and their types). Snapshots are stored per site with a
5
+ version number. Comparing two snapshots gives a list of changes:
6
+
7
+ breaking code using the client can stop working: a method or field is gone, a type
8
+ changed, a parameter became required or disappeared
9
+ additive something new: a method, an optional parameter, a field, a model
10
+
11
+ The client's version follows semantic versioning: breaking -> major, additive -> minor,
12
+ anything else (a description, an example) -> patch. The first version is 1.0.0.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from collections.abc import Callable
19
+ from dataclasses import asdict, dataclass
20
+ from typing import Any, Literal
21
+
22
+ from ghost_api.generator.spec import ClientSpec
23
+ from ghost_api.memory.store import Store
24
+
25
+ Severity = Literal["breaking", "additive", "info"]
26
+ FIRST_VERSION = "1.0.0"
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class Change:
31
+ severity: Severity
32
+ where: str # "get_quotes", "Quote.author"
33
+ text: str # plain words: "returns QuotesResponse instead of list[Quote]"
34
+ # "client": the client Ghost API generates now differs from the last version (the site's
35
+ # data changed, or a newer Ghost API types things differently); "live": a fresh reply
36
+ # from the site differs from what was captured.
37
+ source: str = "client"
38
+
39
+ def line(self) -> str:
40
+ mark = {"breaking": "!", "additive": "+", "info": "~"}[self.severity]
41
+ live = "[live] " if self.source == "live" else ""
42
+ return f"{mark} {live}{self.where}: {self.text}"
43
+
44
+
45
+ def snapshot(spec: ClientSpec) -> dict[str, Any]:
46
+ """The client's public shape, as plain JSON (stable order, so equal APIs compare equal)."""
47
+ return {
48
+ "methods": {
49
+ m.name: {
50
+ "call": f"{m.http_method} {m.host or ''}{m.path}",
51
+ "params": {p.name: {"type": p.type, "required": p.required} for p in m.params},
52
+ "returns": m.response_type,
53
+ "summary": m.summary,
54
+ }
55
+ for m in sorted(spec.methods, key=lambda m: m.name)
56
+ },
57
+ "models": {
58
+ md.name: {f.name: {"type": f.type, "optional": f.optional} for f in md.fields}
59
+ for md in sorted(spec.models, key=lambda md: md.name)
60
+ },
61
+ }
62
+
63
+
64
+ def compare(old: dict[str, Any], new: dict[str, Any]) -> list[Change]:
65
+ changes: list[Change] = []
66
+ old_m, new_m = old.get("methods", {}), new.get("methods", {})
67
+ for name in sorted(old_m.keys() - new_m.keys()):
68
+ changes.append(Change("breaking", name, f"method removed ({old_m[name]['call']})"))
69
+ for name in sorted(new_m.keys() - old_m.keys()):
70
+ changes.append(Change("additive", name, f"new method ({new_m[name]['call']})"))
71
+ for name in sorted(old_m.keys() & new_m.keys()):
72
+ changes += _method_changes(name, old_m[name], new_m[name])
73
+ old_d, new_d = old.get("models", {}), new.get("models", {})
74
+ for name in sorted(old_d.keys() - new_d.keys()):
75
+ changes.append(Change("breaking", name, "model removed"))
76
+ for name in sorted(new_d.keys() - old_d.keys()):
77
+ changes.append(Change("additive", name, "new model"))
78
+ for name in sorted(old_d.keys() & new_d.keys()):
79
+ changes += _field_changes(name, old_d[name], new_d[name])
80
+ return changes
81
+
82
+
83
+ def _method_changes(name: str, old: dict[str, Any], new: dict[str, Any]) -> list[Change]:
84
+ out: list[Change] = []
85
+ if old["call"] != new["call"]:
86
+ out.append(Change("info", name, f"now calls {new['call']} (was {old['call']})"))
87
+ if old["returns"] != new["returns"]:
88
+ out.append(
89
+ Change("breaking", name, f"returns {new['returns']} instead of {old['returns']}")
90
+ )
91
+ if old.get("summary") != new.get("summary"):
92
+ out.append(Change("info", name, "description changed"))
93
+ old_p, new_p = old["params"], new["params"]
94
+ for p in sorted(old_p.keys() - new_p.keys()):
95
+ out.append(Change("breaking", f"{name}({p})", "parameter removed"))
96
+ for p in sorted(new_p.keys() - old_p.keys()):
97
+ if new_p[p]["required"]:
98
+ out.append(Change("breaking", f"{name}({p})", "new required parameter"))
99
+ else:
100
+ out.append(Change("additive", f"{name}({p})", "new optional parameter"))
101
+ for p in sorted(old_p.keys() & new_p.keys()):
102
+ a, b = old_p[p], new_p[p]
103
+ if a["type"] != b["type"]:
104
+ text = f"type {b['type']} instead of {a['type']}"
105
+ out.append(Change("breaking", f"{name}({p})", text))
106
+ if not a["required"] and b["required"]:
107
+ out.append(Change("breaking", f"{name}({p})", "is now required"))
108
+ elif a["required"] and not b["required"]:
109
+ out.append(Change("additive", f"{name}({p})", "is now optional"))
110
+ return out
111
+
112
+
113
+ def _field_changes(model: str, old: dict[str, Any], new: dict[str, Any]) -> list[Change]:
114
+ out: list[Change] = []
115
+ for f in sorted(old.keys() - new.keys()):
116
+ out.append(Change("breaking", f"{model}.{f}", "field removed"))
117
+ for f in sorted(new.keys() - old.keys()):
118
+ out.append(Change("additive", f"{model}.{f}", f"new field ({new[f]['type']})"))
119
+ for f in sorted(old.keys() & new.keys()):
120
+ a, b = old[f]["type"], new[f]["type"]
121
+ if a == b:
122
+ continue
123
+ where = f"{model}.{f}"
124
+ # Reply data: a field that can no longer be None is safe for code reading it;
125
+ # one that may now be None is not.
126
+ if _without_none(a) == _without_none(b):
127
+ if _nullable(a) and not _nullable(b):
128
+ out.append(Change("info", where, "is now always present"))
129
+ else:
130
+ out.append(Change("breaking", where, f"may now be missing ({b})"))
131
+ else:
132
+ out.append(Change("breaking", where, f"type {b} instead of {a}"))
133
+ return out
134
+
135
+
136
+ def _nullable(type_text: str) -> bool:
137
+ return "None" in [part.strip() for part in type_text.split("|")]
138
+
139
+
140
+ def _without_none(type_text: str) -> str:
141
+ return " | ".join(p.strip() for p in type_text.split("|") if p.strip() != "None")
142
+
143
+
144
+ def bump(version: str, changes: list[Change]) -> str:
145
+ major, minor, patch = (int(x) for x in version.split("."))
146
+ if any(c.severity == "breaking" for c in changes):
147
+ return f"{major + 1}.0.0"
148
+ if any(c.severity == "additive" for c in changes):
149
+ return f"{major}.{minor + 1}.0"
150
+ return f"{major}.{minor}.{patch + 1}"
151
+
152
+
153
+ @dataclass
154
+ class VersionInfo:
155
+ version: str
156
+ changes: list[Change]
157
+ previous: str | None # the version this one follows, None for the first
158
+ is_new: bool # differs from the latest stored version
159
+ history: list[dict[str, Any]] # stored versions, newest first (for changelogs)
160
+
161
+ def summary(self) -> str:
162
+ if self.previous is None:
163
+ return f"first version {self.version}"
164
+ if not self.is_new:
165
+ return f"no change since {self.version}"
166
+ counts = {s: sum(c.severity == s for c in self.changes) for s in ("breaking", "additive")}
167
+ parts = [f"{n} {s}" for s, n in counts.items() if n]
168
+ return f"{self.previous} -> {self.version} ({', '.join(parts) or 'small changes'})"
169
+
170
+
171
+ def version_for(store: Store, domain: str, spec: ClientSpec) -> VersionInfo:
172
+ """The version `spec` has (or would get), compared with the latest stored one."""
173
+ history = store.api_versions(domain)
174
+ snap = snapshot(spec)
175
+ if not history:
176
+ return VersionInfo(FIRST_VERSION, [], None, True, history)
177
+ latest = history[0]
178
+ if latest["snapshot"] == snap:
179
+ return VersionInfo(latest["version"], [], latest["version"], False, history)
180
+ changes = compare(latest["snapshot"], snap)
181
+ return VersionInfo(bump(latest["version"], changes), changes, latest["version"], True, history)
182
+
183
+
184
+ def record(store: Store, domain: str, spec: ClientSpec, run_id: int | None) -> VersionInfo:
185
+ """Store `spec` as a new version when it differs from the latest one."""
186
+ info = version_for(store, domain, spec)
187
+ if info.is_new:
188
+ store.add_api_version(
189
+ domain, info.version, snapshot(spec), [asdict(c) for c in info.changes], run_id
190
+ )
191
+ return info
192
+
193
+
194
+ def changelog(spec: ClientSpec, info: VersionInfo) -> str:
195
+ """CHANGELOG.md for a client package: every stored version, plus this one if new."""
196
+ lines = [
197
+ f"# Changelog: {spec.class_name}",
198
+ "",
199
+ f"Versions of the unofficial {spec.domain} client, written by Ghost API when the "
200
+ "site's API changed. `!` breaking, `+` added, `~` other.",
201
+ "",
202
+ ]
203
+ entries = list(info.history)
204
+ if info.is_new:
205
+ entries.insert(
206
+ 0,
207
+ {"version": info.version, "created_at": "unreleased",
208
+ "changes": [asdict(c) for c in info.changes]},
209
+ ) # fmt: skip
210
+ for entry in entries:
211
+ lines += [f"## {entry['version']} ({str(entry['created_at'])[:10]})", ""]
212
+ changes = [Change(**c) for c in entry["changes"]]
213
+ lines += [f"- {c.line()}" for c in changes] or ["- First version."]
214
+ lines.append("")
215
+ return "\n".join(lines)
216
+
217
+
218
+ def to_markdown(site: str, info: VersionInfo, changes: list[Change]) -> str:
219
+ """A pull-request body for `ghost-api diff --markdown`."""
220
+ if not changes:
221
+ return f"No API changes for **{site}** (client {info.version})."
222
+ breaking = [c for c in changes if c.severity == "breaking"]
223
+ lines = [
224
+ f"## API changes on {site}",
225
+ "",
226
+ f"Client version **{info.previous or '-'} -> {info.version}**"
227
+ + (f": **{len(breaking)} breaking**" if breaking else ""),
228
+ "",
229
+ ]
230
+ for title, severity in (("Breaking", "breaking"), ("Added", "additive"), ("Other", "info")):
231
+ group = [c for c in changes if c.severity == severity]
232
+ if group:
233
+ lines += [f"### {title}", ""] + [f"- `{c.where}`: {c.text}" for c in group] + [""]
234
+ lines.append("Generated by [Ghost API](https://github.com/Danielraj5605/Ghost-api).")
235
+ return "\n".join(lines)
236
+
237
+
238
+ def changes_json(changes: list[Change]) -> list[dict[str, Any]]:
239
+ return [asdict(c) for c in changes]
240
+
241
+
242
+ def dumps(snap: dict[str, Any]) -> str:
243
+ return json.dumps(snap, sort_keys=True)
244
+
245
+
246
+ # --- live check: compare a fresh reply with the stored response schema ---------------------
247
+
248
+
249
+ @dataclass(frozen=True)
250
+ class FieldShape:
251
+ types: frozenset[str]
252
+ required: bool # present in every captured reply
253
+
254
+
255
+ def schema_fields(schema: dict[str, Any] | None, max_depth: int = 4) -> dict[str, FieldShape]:
256
+ """{"quotes[].author.name": FieldShape({"string"}, True), ...} for a JSON schema."""
257
+ out: dict[str, FieldShape] = {}
258
+
259
+ def types(node: dict[str, Any]) -> frozenset[str]:
260
+ kind = node.get("type")
261
+ found = set(kind) if isinstance(kind, list) else {kind} if kind else set()
262
+ for option in node.get("anyOf", []):
263
+ found |= types(option)
264
+ return frozenset(t for t in found if t)
265
+
266
+ def walk(node: dict[str, Any], prefix: str, required: bool, depth: int) -> None:
267
+ kinds = types(node)
268
+ if prefix:
269
+ out[prefix] = FieldShape(kinds, required)
270
+ if depth >= max_depth:
271
+ return
272
+ if "array" in kinds and isinstance(node.get("items"), dict):
273
+ walk(node["items"], prefix + "[]", required, depth + 1)
274
+ needed = set(node.get("required", []))
275
+ for key, child in (node.get("properties") or {}).items():
276
+ if isinstance(child, dict):
277
+ path = f"{prefix}.{key}" if prefix else key
278
+ walk(child, path, required and key in needed, depth + 1)
279
+ for option in node.get("anyOf", []):
280
+ if isinstance(option, dict) and option.get("properties"):
281
+ walk({**option, "type": None}, prefix, required, depth)
282
+
283
+ if schema:
284
+ walk(schema, "", True, 0)
285
+ return out
286
+
287
+
288
+ def live_changes(
289
+ where: str, stored: dict[str, Any] | None, fresh: dict[str, Any] | None
290
+ ) -> list[Change]:
291
+ """How a fresh reply differs from what was captured before. A field missing from one
292
+ reply is only breaking when every earlier reply had it."""
293
+ before, now = schema_fields(stored), schema_fields(fresh)
294
+ out: list[Change] = []
295
+ for path in sorted(before.keys() - now.keys()):
296
+ if before[path].required and "[]" not in path.rstrip("[]"):
297
+ out.append(
298
+ Change("breaking", f"{where} {path}", "field missing from the reply", "live")
299
+ )
300
+ for path in sorted(now.keys() - before.keys()):
301
+ out.append(Change("additive", f"{where} {path}", "new field in the reply", "live"))
302
+ for path in sorted(before.keys() & now.keys()):
303
+ old, new = before[path].types - {"null"}, now[path].types - {"null"}
304
+ if old and new and not (new <= old or {"integer", "number"} >= old | new):
305
+ text = f"now {'/'.join(sorted(new))}, was {'/'.join(sorted(old))}"
306
+ out.append(Change("breaking", f"{where} {path}", text, "live"))
307
+ return out
308
+
309
+
310
+ def live_check(
311
+ store: Store, site: str, on_progress: Callable[[str], None] | None = None
312
+ ) -> list[Change]:
313
+ """Call each operation once (read-only, rate-limited, the site's rules apply) and
314
+ compare each reply's shape with what was captured."""
315
+ from ghost_api.analysis.schema import build_schema
316
+ from ghost_api.probe.replay import ReplayRefused
317
+ from ghost_api.runtime import OperationError, SiteRuntime
318
+
319
+ try:
320
+ runtime = SiteRuntime(store, site)
321
+ except OperationError as exc:
322
+ if on_progress is not None:
323
+ on_progress(f"live check skipped: {exc}")
324
+ return []
325
+ stored = {e.key: e.response_schema for e in store.current_endpoints(site)}
326
+ changes: list[Change] = []
327
+ with runtime:
328
+ for op in runtime.operations():
329
+ inputs = {i.name: i.example for i in op.inputs if i.example}
330
+ try:
331
+ result = runtime.call(op, inputs)
332
+ except (OperationError, ReplayRefused) as exc:
333
+ changes.append(Change("info", op.name, f"not checked live: {exc}", "live"))
334
+ continue
335
+ if not result.ok:
336
+ status = result.status or "network error"
337
+ changes.append(Change("breaking", op.name, f"live call failed ({status})", "live"))
338
+ continue
339
+ changes += live_changes(
340
+ op.name, stored.get(op.endpoint.key), build_schema([result.body])
341
+ )
342
+ if on_progress is not None:
343
+ on_progress(f"checked {op.name} live")
344
+ return changes
ghost_api/drive.py ADDED
@@ -0,0 +1,211 @@
1
+ """Explore a site one step at a time, with the decisions made elsewhere (an AI assistant).
2
+
3
+ `ghost-api mcp --explore` hands these steps to Claude (or any MCP client): the assistant
4
+ reads the page and decides; Ghost API acts with all its rules (robots.txt, rate limit,
5
+ same site only, no destructive or login elements, no private network), records every
6
+ request, and at the end tests the endpoints, writes the client, report and version, just
7
+ like `ghost-api run`. No LLM key is needed: the assistant is the agent.
8
+
9
+ Playwright's browser belongs to the thread that opened it, so each session runs all its
10
+ work on one worker thread of its own.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import time
16
+ from collections.abc import Callable
17
+ from concurrent.futures import ThreadPoolExecutor
18
+ from pathlib import Path
19
+ from typing import Any, TypeVar
20
+ from urllib.parse import urlsplit
21
+
22
+ from ghost_api.agent.orchestrator import BotProtection, Explorer
23
+ from ghost_api.agent.prompts import format_page
24
+ from ghost_api.agent.tools import Decision
25
+ from ghost_api.analysis.analyzer import analyze
26
+ from ghost_api.browser.driver import BrowserSession
27
+ from ghost_api.config import check_language, default_db_path, load_env
28
+ from ghost_api.events import EventBus
29
+ from ghost_api.export.site_data import endpoint_summary
30
+ from ghost_api.memory.knowledge import load_memory, memory_prompt
31
+ from ghost_api.memory.store import Store
32
+ from ghost_api.net import RateLimiter
33
+ from ghost_api.pipeline import RunOptions, Session, finish, open_session, robots_policy
34
+ from ghost_api.site import site_key
35
+ from ghost_api.workflows import NotAllowed, RunCollector, enforce
36
+
37
+ T = TypeVar("T")
38
+ MAX_STEPS = 60 # a ceiling, so an assistant can't keep a browser busy forever
39
+
40
+
41
+ class DriveError(Exception):
42
+ """Something the assistant should be told plainly (a closed session, a bad action)."""
43
+
44
+
45
+ class DriveSession:
46
+ def __init__(
47
+ self,
48
+ url: str,
49
+ goal: str,
50
+ *,
51
+ db: Path | None = None,
52
+ out: Path = Path("output"),
53
+ lang: str = "python",
54
+ rate_limit: float = 1.0,
55
+ max_steps: int = MAX_STEPS,
56
+ ) -> None:
57
+ self.url, self.goal = url, goal
58
+ self.db = db or default_db_path()
59
+ self.out = out
60
+ self.lang = check_language(lang)
61
+ self.rate_limit = rate_limit
62
+ self.max_steps = max_steps
63
+ self.collector = RunCollector()
64
+ self.closed = False
65
+ self._worker = ThreadPoolExecutor(max_workers=1, thread_name_prefix="ghost-drive")
66
+ self._store: Store | None = None
67
+ self._session: Session | None = None
68
+ self._browser: BrowserSession | None = None
69
+ self._explorer: Explorer | None = None
70
+ self._started = 0.0
71
+
72
+ # --- public, thread-safe: every call runs on the session's own worker thread ---
73
+
74
+ def start(self) -> dict[str, Any]:
75
+ return self._run(self._start)
76
+
77
+ def page(self) -> str:
78
+ return self._run(self._page)
79
+
80
+ def act(self, action: str, **arguments: Any) -> dict[str, Any]:
81
+ return self._run(lambda: self._act(action, arguments))
82
+
83
+ def endpoints(self) -> list[dict[str, Any]]:
84
+ return self._run(self._endpoints)
85
+
86
+ def finish(self, goal_covered: bool = False, note: str = "") -> dict[str, Any]:
87
+ try:
88
+ return self._run(lambda: self._finish(goal_covered, note))
89
+ finally:
90
+ self._worker.shutdown(wait=False)
91
+
92
+ # --- the work ---
93
+
94
+ def _run(self, work: Callable[[], T]) -> T:
95
+ if self.closed:
96
+ raise DriveError("this exploration has finished; start a new one")
97
+ return self._worker.submit(work).result()
98
+
99
+ def _start(self) -> dict[str, Any]:
100
+ load_env()
101
+ host = urlsplit(self.url).hostname or ""
102
+ policy = enforce(site_key(self.url) or host, use_agent=True) # the assistant decides
103
+ bus = EventBus()
104
+ bus.subscribe(self.collector)
105
+ limiter = RateLimiter(self.rate_limit)
106
+ robots, allowed = robots_policy(self.url, False, limiter, bus)
107
+ if not allowed:
108
+ raise NotAllowed(f"robots.txt does not allow {self.url}")
109
+ self._store = Store(self.db)
110
+ self._session = s = open_session(
111
+ self._store, bus, host, self.url, self.goal, False, limiter, robots, None,
112
+ min(60, policy.max_direct_calls or 60),
113
+ screens_root=self.db, lang=self.lang,
114
+ ) # fmt: skip
115
+ known = load_memory(self._store, s.site)
116
+ if known is not None:
117
+ s.memory = memory_prompt(known.endpoints, known.lessons)
118
+ self._browser = s.browser().__enter__()
119
+ self._explorer = Explorer(
120
+ self._browser, None, lambda: s.recorder.captured, self.goal, self.url, host,
121
+ robots=s.robots, on_step=s.on_step, replayer=s.replayer,
122
+ raw_requests=lambda: s.recorder.raw, add_sample=s.recorder.add, memory=s.memory,
123
+ ) # fmt: skip
124
+ self._started = time.monotonic()
125
+ self._explorer.begin()
126
+ return {
127
+ "site": s.site,
128
+ "memory": s.memory or "nothing known about this site yet",
129
+ "page": self._page(),
130
+ "endpoints": self._endpoints(),
131
+ }
132
+
133
+ def _require(self) -> tuple[Session, BrowserSession, Explorer]:
134
+ if self._session is None or self._browser is None or self._explorer is None:
135
+ raise DriveError("start the exploration first (open_site)")
136
+ return self._session, self._browser, self._explorer
137
+
138
+ def _page(self) -> str:
139
+ session, browser, _ = self._require()
140
+ return format_page(browser.snapshot(session.host, session.robots))
141
+
142
+ def _act(self, action: str, arguments: dict[str, Any]) -> dict[str, Any]:
143
+ _, _, explorer = self._require()
144
+ if action in ("finish", "ask_user"):
145
+ raise DriveError(f"'{action}' is not a step here: use finish_exploration instead")
146
+ if len(explorer.steps) >= self.max_steps:
147
+ raise DriveError(f"step limit reached ({self.max_steps}); finish the exploration")
148
+ try:
149
+ decision = Decision(thought=str(arguments.pop("why", "") or "(assistant)"),
150
+ action=action, **arguments) # type: ignore[arg-type] # fmt: skip
151
+ except ValueError as exc:
152
+ raise DriveError(f"not a valid {action}: {exc}") from exc
153
+ try:
154
+ record = explorer.act(decision)
155
+ except BotProtection as exc:
156
+ raise DriveError(
157
+ f"bot-protection page ({exc}): Ghost API never tries to get past it. Finish, "
158
+ "or record the site yourself and use `ghost-api import`."
159
+ ) from exc
160
+ return {
161
+ "result": record.result,
162
+ "new_endpoints": record.new_endpoints,
163
+ "page": self._page(),
164
+ }
165
+
166
+ def _endpoints(self) -> list[dict[str, Any]]:
167
+ session, _, _ = self._require()
168
+ found = analyze(session.recorder.captured)
169
+ return [_compact(endpoint_summary(e, session.site)) for e in found]
170
+
171
+ def _finish(self, goal_covered: bool, note: str) -> dict[str, Any]:
172
+ session, browser, explorer = self._require()
173
+ self.closed = True
174
+ try:
175
+ browser.__exit__(None, None, None)
176
+ covered = goal_covered and bool(analyze(session.recorder.captured))
177
+ status = "goal-covered" if covered else "finished"
178
+ reason = note or f"explored by an assistant in {len(explorer.steps)} step(s)"
179
+ opts = RunOptions(lang=self.lang, max_steps=self.max_steps, out=self.out)
180
+ finish(session, status, reason, self._started, opts)
181
+ finally:
182
+ if self._store is not None:
183
+ self._store.close()
184
+ data = self.collector.data
185
+ client = data.get("client") or {}
186
+ return {
187
+ "status": data.get("status"),
188
+ "site": data.get("site"),
189
+ "endpoints": [_compact(e) for e in data.get("endpoints", [])],
190
+ "client": {
191
+ "class": client.get("class_name"),
192
+ "directory": client.get("directory"),
193
+ "files": client.get("files", []),
194
+ }
195
+ if client
196
+ else None,
197
+ "report": data.get("report_html") or data.get("report"),
198
+ "openapi": data.get("openapi"),
199
+ "network": data.get("network"),
200
+ }
201
+
202
+
203
+ def _compact(summary: dict[str, Any]) -> dict[str, Any]:
204
+ """What an assistant needs to decide, in few tokens: no schemas or examples."""
205
+ keep = ("method", "host", "path", "status", "statuses", "samples", "pagination",
206
+ "auth_type", "fields", "confidence") # fmt: skip
207
+ out = {k: summary[k] for k in keep if summary.get(k) not in (None, [], {}, "")}
208
+ params = summary.get("params") or []
209
+ if params:
210
+ out["params"] = [f"{p['name']} ({p['location']})" for p in params][:12]
211
+ return out
ghost_api/errors.py ADDED
@@ -0,0 +1,106 @@
1
+ """Turn common low-level errors into plain explanations with a fix (NFR-23)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ # (pattern in the error text, explanation and fix). The order matches ERROR_CODES.
8
+ _KNOWN: list[tuple[re.Pattern[str], str]] = [
9
+ (
10
+ re.compile(r"Executable doesn't exist|playwright install", re.I),
11
+ "Chromium isn't installed for Playwright. Run: playwright install chromium",
12
+ ),
13
+ (
14
+ re.compile(r"ERR_NAME_NOT_RESOLVED|getaddrinfo failed|Name or service not known", re.I),
15
+ "The website's address could not be found. Check the URL and your internet connection.",
16
+ ),
17
+ (
18
+ re.compile(r"ERR_CONNECTION_REFUSED|Connection refused|actively refused", re.I),
19
+ "The website refused the connection. Is the URL (and port) right, and is the site up?",
20
+ ),
21
+ (
22
+ re.compile(r"ERR_UNSAFE_PORT", re.I),
23
+ "Browsers refuse to open this port for safety reasons. Use a different port.",
24
+ ),
25
+ (
26
+ re.compile(r"ERR_INTERNET_DISCONNECTED|ERR_NETWORK_CHANGED", re.I),
27
+ "No internet connection.",
28
+ ),
29
+ (
30
+ # Only the browser's own timeouts: an LLM or robots.txt timeout is not the website.
31
+ re.compile(r"Timeout \d+ms exceeded|net::ERR_(CONNECTION_)?TIMED_OUT", re.I),
32
+ "The website took too long to respond. Try again, or check that it loads in a browser.",
33
+ ),
34
+ (
35
+ re.compile(r"ERR_CERT_|CERTIFICATE_VERIFY_FAILED", re.I),
36
+ "The website's security certificate is not valid.",
37
+ ),
38
+ (
39
+ re.compile(r"file is not a database|database disk image is malformed", re.I),
40
+ "The database file is damaged or is not a Ghost API database. Move it aside "
41
+ "(or use --db to pick another file) and Ghost API will start a fresh one.",
42
+ ),
43
+ (
44
+ re.compile(r"database is locked", re.I),
45
+ "The database is busy, probably because another ghost-api command is running. "
46
+ "Wait for it to finish, or use --db to pick another file.",
47
+ ),
48
+ ]
49
+
50
+
51
+ # Stable codes for the explanations above, in the same order: for logs, scripts and bug
52
+ # reports, so nothing depends on the wording of a message.
53
+ ERROR_CODES = (
54
+ "browser-missing",
55
+ "dns-failed",
56
+ "connection-refused",
57
+ "unsafe-port",
58
+ "offline",
59
+ "site-timeout",
60
+ "bad-certificate",
61
+ "database-damaged",
62
+ "database-locked",
63
+ )
64
+
65
+
66
+ def error_code(exc: BaseException) -> str:
67
+ """A stable short code for `exc` (see ERROR_CODES), 'network-denied',
68
+ 'response-too-large', or 'unexpected'."""
69
+ from ghost_api.netguard import PolicyDenied, ResponseTooLarge
70
+
71
+ chain = _chain(exc)
72
+ if any(isinstance(e, PolicyDenied) for e in chain):
73
+ return "network-denied"
74
+ if any(isinstance(e, ResponseTooLarge) for e in chain):
75
+ return "response-too-large"
76
+ for error in chain:
77
+ for (pattern, _), code in zip(_KNOWN, ERROR_CODES, strict=True):
78
+ if pattern.search(str(error)):
79
+ return code
80
+ return "unexpected"
81
+
82
+
83
+ def _chain(exc: BaseException) -> list[BaseException]:
84
+ chain = [exc]
85
+ while chain[-1].__cause__ is not None and len(chain) < 5:
86
+ chain.append(chain[-1].__cause__)
87
+ return chain
88
+
89
+
90
+ def friendly_error(exc: BaseException) -> str:
91
+ """A short, human explanation of `exc`; falls back to its first line, plus the
92
+ underlying cause when there is one, so the real reason is not lost."""
93
+ for error in _chain(exc):
94
+ for pattern, message in _KNOWN:
95
+ if pattern.search(str(error)):
96
+ return message
97
+ text = _first_line(exc)
98
+ cause = exc.__cause__
99
+ if cause is not None and _first_line(cause) not in text:
100
+ text += f" (cause: {_first_line(cause)})"
101
+ return text[:300]
102
+
103
+
104
+ def _first_line(exc: BaseException) -> str:
105
+ text = str(exc).strip()
106
+ return text.splitlines()[0] if text else type(exc).__name__