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/pipeline.py ADDED
@@ -0,0 +1,745 @@
1
+ """The Ghost API workflow, with no user interface.
2
+
3
+ Everything here emits events (`ghost_api/events.py`) instead of printing, so the terminal,
4
+ the web dashboard and the database all see the same run. Every event is also saved in
5
+ the `run_events` table, so any run can be replayed later.
6
+
7
+ Workflow: robots.txt check -> explore (agent or plain visit) -> test endpoints directly ->
8
+ generate the client -> verify it against the live API (repairing failures) -> remember
9
+ lessons -> write report.md and openapi.yaml.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import contextlib
15
+ import time
16
+ from collections.abc import Callable
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import Any, Literal
20
+ from urllib.parse import urlsplit
21
+
22
+ from ghost_api import drift
23
+ from ghost_api.agent.orchestrator import AgentResult, Explorer, StepRecord
24
+ from ghost_api.analysis.analyzer import analyze, endpoint_keys
25
+ from ghost_api.analysis.detectors import is_bot_challenge
26
+ from ghost_api.analysis.exposure import exposures, summary_line
27
+ from ghost_api.analysis.models import Endpoint
28
+ from ghost_api.audit import AuditLog
29
+ from ghost_api.browser import auth_state
30
+ from ghost_api.browser.driver import BrowserSession
31
+ from ghost_api.browser.gate import RequestGate
32
+ from ghost_api.capture.models import CapturedRequest
33
+ from ghost_api.capture.recorder import TrafficRecorder
34
+ from ghost_api.errors import error_code, friendly_error
35
+ from ghost_api.events import (
36
+ AgentStep,
37
+ ClientGenerated,
38
+ EndpointsUpdated,
39
+ Event,
40
+ EventBus,
41
+ HypothesisTested,
42
+ NetworkSummary,
43
+ Notice,
44
+ Phase,
45
+ PhaseChanged,
46
+ ProbeFinished,
47
+ RequestCaptured,
48
+ RunFinished,
49
+ RunStarted,
50
+ Screenshot,
51
+ UsageUpdated,
52
+ VerifyRound,
53
+ )
54
+ from ghost_api.export import write_exports
55
+ from ghost_api.export.site_data import display_label, endpoint_summary
56
+ from ghost_api.generator import GenerationResult, NothingToGenerate, generate_from, plan_for
57
+ from ghost_api.generator.python_gen import GenerationError
58
+ from ghost_api.generator.ts_gen import camel, ts_type
59
+ from ghost_api.llm.gateway import LLMGateway
60
+ from ghost_api.memory.knowledge import (
61
+ DomainMemory,
62
+ discovery_lessons,
63
+ rebuild_raw,
64
+ write_lessons,
65
+ )
66
+ from ghost_api.memory.store import Store
67
+ from ghost_api.net import DenyPaths, MultiHostRobotsPolicy, RateLimiter, RobotsPolicy, fetch_robots
68
+ from ghost_api.netguard import NetworkPolicy
69
+ from ghost_api.probe.prober import Prober, ProbeReport
70
+ from ghost_api.probe.replay import Replayer, is_replayable
71
+ from ghost_api.project import site_policy
72
+ from ghost_api.site import site_key
73
+ from ghost_api.verify.verifier import Round, Verifier
74
+
75
+ # With no agent, the page is just loaded and scrolled to the bottom this many times.
76
+ AUTO_SCROLLS = 5
77
+ # Agent steps allowed when verification sends the agent back to the site.
78
+ REPLAN_STEPS = 6
79
+ # Only runs with at least this many agent steps are worth an LLM call for lessons.
80
+ MIN_STEPS_FOR_LESSONS = 2
81
+ # Runs that ended this way are not probed or turned into a client.
82
+ STOPPED = ("interrupted", "bot-protection", "failed")
83
+
84
+
85
+ @dataclass
86
+ class RunOptions:
87
+ lang: str = "python"
88
+ headed: bool = False
89
+ max_steps: int = 40
90
+ max_minutes: float = 15.0
91
+ max_probes: int = 60
92
+ no_probe: bool = False
93
+ no_generate: bool = False
94
+ no_verify: bool = False
95
+ out: Path = Path("output")
96
+ third_party_robots: bool = False # also check other sites' robots.txt (browser calls)
97
+
98
+
99
+ def screenshots_dir(db_path: Path, run_id: int) -> Path:
100
+ """Where a run's browser screenshots go: next to the database, never in the output."""
101
+ return db_path.parent / f"{db_path.stem}_files" / "runs" / str(run_id)
102
+
103
+
104
+ # --- stage 0: robots.txt ---------------------------------------------------------------------
105
+
106
+
107
+ def robots_policy(
108
+ url: str, ignore: bool, limiter: RateLimiter, bus: EventBus
109
+ ) -> tuple[RobotsPolicy, bool]:
110
+ """The site's robots.txt rules, and whether the start page may be visited.
111
+
112
+ Returns a ``MultiHostRobotsPolicy`` that lazily fetches robots.txt for any
113
+ new subdomain the agent navigates to (Issue 3).
114
+ """
115
+ host = urlsplit(url).hostname or ""
116
+ policy, _ = site_policy(site_key(url) or host)
117
+ robots: RobotsPolicy
118
+ robots, allowed = _robots_rules(url, host, ignore, limiter, bus)
119
+ if policy.deny_paths:
120
+ bus.emit(Notice(f"Project rules: {len(policy.deny_paths)} path(s) are never requested "
121
+ f"({', '.join(policy.deny_paths[:4])})")) # fmt: skip
122
+ robots = DenyPaths(robots, policy.deny_paths)
123
+ if not robots.allows(url):
124
+ bus.emit(Notice(f"Stopped: {url} is denied by the project rules", "error"))
125
+ return robots, False
126
+ return robots, allowed
127
+
128
+
129
+ def _robots_rules(
130
+ url: str, host: str, ignore: bool, limiter: RateLimiter, bus: EventBus
131
+ ) -> tuple[MultiHostRobotsPolicy, bool]:
132
+ if ignore:
133
+ bus.emit(Notice("robots.txt check skipped (--ignore-robots)", "warning"))
134
+ return (
135
+ MultiHostRobotsPolicy(
136
+ RobotsPolicy.allow_all(host),
137
+ limiter,
138
+ lambda u, _l: RobotsPolicy.allow_all(urlsplit(u).hostname or ""),
139
+ ),
140
+ True,
141
+ )
142
+ robots = fetch_robots(url, limiter)
143
+ verdict = robots.verdict(url)
144
+ if not verdict.allowed:
145
+ bus.emit(
146
+ Notice(
147
+ f"Stopped: {verdict.reason} for {url}",
148
+ "error",
149
+ "Use --ignore-robots only if you have permission to access this site.",
150
+ )
151
+ )
152
+ return MultiHostRobotsPolicy(robots, limiter), False
153
+ bus.emit(Notice(f"robots.txt: {verdict.reason}", "success"))
154
+ if verdict.crawl_delay:
155
+ limiter.slow_down_to(verdict.crawl_delay)
156
+ bus.emit(Notice(f"Honoring the site's Crawl-delay of {verdict.crawl_delay}s"))
157
+ return MultiHostRobotsPolicy(robots, limiter), True
158
+
159
+
160
+ # --- the session -----------------------------------------------------------------------------
161
+
162
+
163
+ @dataclass
164
+ class Session:
165
+ """Everything one run or refresh shares."""
166
+
167
+ store: Store
168
+ bus: EventBus
169
+ run_id: int
170
+ host: str # the server: first-party vs third-party, robots.txt
171
+ start_url: str
172
+ goal: str
173
+ headed: bool
174
+ robots: RobotsPolicy
175
+ recorder: TrafficRecorder
176
+ replayer: Replayer
177
+ gateway: LLMGateway | None
178
+ screens: Path | None = None
179
+ stop: Callable[[], bool] | None = None # e.g. the dashboard's Stop button
180
+ memory: str = ""
181
+ steps: list[StepRecord] = field(default_factory=list)
182
+ site: str = "" # memory and output folder: host or host/section (ghost_api/site.py)
183
+ network: NetworkPolicy | None = None # where the browser may connect (netguard.py)
184
+ login: dict[str, Any] | None = None # a saved login for the site (auth_state.py)
185
+ third_party_robots: bool = False # RunOptions.third_party_robots
186
+ audit: AuditLog | None = None # every request sent or blocked (audit.py)
187
+ _blocked: set[str] = field(default_factory=set)
188
+
189
+ def emit(self, event: Event) -> None:
190
+ self.bus.emit(event)
191
+
192
+ def _on_block(self, message: str) -> None:
193
+ """A request the network rules stopped: said once in the run log (and audited)."""
194
+ if self.audit is not None:
195
+ self.audit.blocked(message)
196
+ if message not in self._blocked:
197
+ self._blocked.add(message)
198
+ self.emit(Notice(message, "warning"))
199
+
200
+ def browser(self) -> BrowserSession:
201
+ """A browser whose every request follows the run's rules: no private addresses
202
+ (unless the target itself is local), robots.txt and the rate limit."""
203
+ gate = RequestGate(
204
+ self.host,
205
+ self.robots,
206
+ self.replayer.limiter,
207
+ self._on_block,
208
+ third_party_robots=self.third_party_robots,
209
+ )
210
+ return BrowserSession(
211
+ self.start_url,
212
+ self.recorder,
213
+ headed=self.headed,
214
+ policy=self.network,
215
+ gate=gate,
216
+ on_block=self._on_block,
217
+ storage_state=self.login,
218
+ )
219
+
220
+ def phase(self, phase: Phase) -> None:
221
+ self.emit(PhaseChanged(phase))
222
+
223
+ def on_step(self, rec: StepRecord) -> None:
224
+ self.steps.append(rec)
225
+ self.store.add_step(
226
+ self.run_id,
227
+ rec.step,
228
+ rec.thought,
229
+ rec.action,
230
+ rec.args,
231
+ rec.result,
232
+ len(rec.new_endpoints),
233
+ )
234
+ self.emit(
235
+ AgentStep(
236
+ step=rec.step,
237
+ action=rec.action,
238
+ args=dict(rec.args),
239
+ thought=rec.thought,
240
+ result=rec.result,
241
+ new_endpoints=list(rec.new_endpoints),
242
+ requests=rec.requests,
243
+ screenshot=rec.screenshot,
244
+ box=rec.box,
245
+ )
246
+ )
247
+ if self.gateway is not None:
248
+ self.emit(usage_event(self.gateway))
249
+
250
+ def _shooter(self, browser: BrowserSession) -> Callable[[str], str | None] | None:
251
+ if self.screens is None:
252
+ return None
253
+ folder = self.screens
254
+
255
+ def shoot(name: str) -> str | None:
256
+ file = f"{name}.jpg"
257
+ if not browser.screenshot(folder / file):
258
+ return None
259
+ # Step screenshots travel in their AgentStep event; announce the others (the
260
+ # page as first loaded), or a run that finishes at step 1 shows no picture.
261
+ if not name.startswith("step-"):
262
+ self.emit(Screenshot(file, browser.url))
263
+ return file
264
+
265
+ return shoot
266
+
267
+ def explore(
268
+ self,
269
+ goal: str,
270
+ max_steps: int,
271
+ max_minutes: float,
272
+ ask_user: Callable[[str], str] | None = None,
273
+ ) -> AgentResult:
274
+ """Open the browser and let the agent work toward `goal`."""
275
+ assert self.gateway is not None
276
+ with self.browser() as browser:
277
+ return Explorer(
278
+ browser=browser,
279
+ llm=self.gateway,
280
+ captured=lambda: self.recorder.captured,
281
+ goal=goal,
282
+ start_url=self.start_url,
283
+ target_host=self.host,
284
+ max_steps=max_steps,
285
+ max_minutes=max_minutes,
286
+ robots=self.robots,
287
+ on_step=self.on_step,
288
+ replayer=self.replayer,
289
+ raw_requests=lambda: self.recorder.raw,
290
+ add_sample=self.recorder.add,
291
+ ask_user=ask_user,
292
+ memory=self.memory,
293
+ screenshot=self._shooter(browser),
294
+ should_stop=self.stop,
295
+ ).run()
296
+
297
+ def visit(self, scrolls: int) -> str | None:
298
+ """Load the start page and scroll, without the agent (no LLM). Returns the page
299
+ title when it is a bot-protection page (nothing more is done there), else None."""
300
+ with self.browser() as browser:
301
+ browser.goto(self.start_url)
302
+ snapshot = browser.snapshot(self.host, self.robots)
303
+ if is_bot_challenge(snapshot.title, snapshot.text):
304
+ return snapshot.title # never try to get past bot protection (NFR-19)
305
+ for _ in range(scrolls):
306
+ if self.stop is not None and self.stop():
307
+ break
308
+ browser.scroll("bottom")
309
+ shoot = self._shooter(browser)
310
+ if shoot:
311
+ shoot("visit")
312
+ return None
313
+
314
+ def replan(self, sub_goal: str) -> None:
315
+ """Verification sends the agent back with a focused sub-goal (e.g. fresh session)."""
316
+ if self.gateway is None:
317
+ return
318
+ self.emit(Notice(f"Replanning: {sub_goal}"))
319
+ with contextlib.suppress(Exception):
320
+ self.explore(sub_goal, REPLAN_STEPS, 3)
321
+
322
+
323
+ def open_session(
324
+ store: Store,
325
+ bus: EventBus,
326
+ host: str,
327
+ start_url: str,
328
+ goal: str,
329
+ headed: bool,
330
+ limiter: RateLimiter,
331
+ robots: RobotsPolicy,
332
+ gateway: LLMGateway | None,
333
+ max_probes: int,
334
+ screens_root: Path | None = None,
335
+ mode: str = "run",
336
+ lang: str = "python",
337
+ site: str | None = None,
338
+ ) -> Session:
339
+ """Create the run in the database and start recording its events.
340
+
341
+ `site` defaults to the start URL's site; a refresh passes the name its memory is under."""
342
+ site = site or site_key(start_url) or host
343
+ run_id = store.create_run(site, start_url, goal)
344
+ bus.subscribe(lambda event: store.add_event(run_id, event.to_dict()))
345
+ if gateway is not None:
346
+ gateway.store = store # cache LLM answers in the same database
347
+
348
+ def on_capture(req: CapturedRequest) -> None:
349
+ store.add_request(run_id, req)
350
+ bus.emit(RequestCaptured(req.method, req.url, req.status, req.third_party))
351
+
352
+ recorder = TrafficRecorder(host, on_capture=on_capture)
353
+ network = NetworkPolicy.for_target(start_url)
354
+ replayer = Replayer(host, limiter, robots, max_requests=max_probes, policy=network)
355
+ screens = screenshots_dir(screens_root, run_id) if screens_root is not None else None
356
+ session = Session(
357
+ store,
358
+ bus,
359
+ run_id,
360
+ host,
361
+ start_url,
362
+ goal,
363
+ headed,
364
+ robots,
365
+ recorder,
366
+ replayer,
367
+ gateway,
368
+ screens,
369
+ site=site,
370
+ network=network,
371
+ login=_saved_login(store, site, bus),
372
+ audit=AuditLog(store.path, site, run_id),
373
+ )
374
+ audit = session.audit
375
+ assert audit is not None
376
+ replayer.on_sent = lambda method, url, status, decision: audit.record(
377
+ "direct", method, url, status, decision
378
+ )
379
+ bus.emit(
380
+ RunStarted(run_id, site, start_url, goal, "refresh" if mode == "refresh" else "run", lang)
381
+ )
382
+ if network.allow_private:
383
+ bus.emit(Notice("Local target: this computer and the local network may be reached"))
384
+ return session
385
+
386
+
387
+ def network_summary(s: Session) -> NetworkSummary:
388
+ third_party = sorted({urlsplit(c.url).hostname or "" for c in s.recorder.captured
389
+ if c.third_party} - {""}) # fmt: skip
390
+ return NetworkSummary(
391
+ target=s.host,
392
+ robots=s.robots.reason,
393
+ rate_limit=round(1 / s.replayer.limiter.interval, 2) if s.replayer.limiter.interval else 0,
394
+ local_target=bool(s.network and s.network.allow_private),
395
+ saved_login=s.login is not None,
396
+ api_calls_seen=len(s.recorder.captured),
397
+ direct_calls=s.replayer.requests_made,
398
+ third_party_hosts=third_party,
399
+ blocked=sorted(s._blocked),
400
+ llm_calls=s.gateway.usage.answered if s.gateway is not None else 0,
401
+ )
402
+
403
+
404
+ def _saved_login(store: Store, site: str, bus: EventBus) -> dict[str, Any] | None:
405
+ """The site's saved login (ghost-api login), announced in the run log."""
406
+ try:
407
+ found = auth_state.load(store.path, site)
408
+ except auth_state.LoginError as exc:
409
+ bus.emit(Notice(str(exc), "warning"))
410
+ return None
411
+ if found is None:
412
+ return None
413
+ state, meta = found
414
+ days = auth_state.age_days(meta)
415
+ note = f"Using your saved login for {site} (saved {days:.0f} day(s) ago)"
416
+ bus.emit(Notice(note, "warning" if days > 14 else "info",
417
+ "If pages show you logged out, run ghost-api login again." if days > 14
418
+ else "")) # fmt: skip
419
+ return state
420
+
421
+
422
+ # --- run and refresh -------------------------------------------------------------------------
423
+
424
+
425
+ def explore_site(
426
+ s: Session,
427
+ opts: RunOptions,
428
+ use_agent: bool,
429
+ ask_user: Callable[[str], str] | None = None,
430
+ ) -> tuple[str, str]:
431
+ """Workflow stage 1: explore. Returns (status, reason)."""
432
+ s.phase("explore")
433
+ try:
434
+ if not use_agent:
435
+ challenge = s.visit(AUTO_SCROLLS)
436
+ if challenge is not None:
437
+ return "bot-protection", f"bot-protection page detected: {challenge!r}"
438
+ if s.stop is not None and s.stop():
439
+ return "interrupted", "stopped by user"
440
+ return "captured", "--no-agent: scrolled the page"
441
+ result = s.explore(s.goal, opts.max_steps, opts.max_minutes, ask_user=ask_user)
442
+ return result.status, result.reason
443
+ except KeyboardInterrupt:
444
+ return "interrupted", "stopped by user"
445
+ except Exception as exc:
446
+ s.emit(Notice(friendly_error(exc), "error", f"error code: {error_code(exc)}"))
447
+ return "failed", friendly_error(exc)
448
+
449
+
450
+ def refresh_site(s: Session, mem: DomainMemory, max_steps: int) -> tuple[str, str]:
451
+ """Re-check known endpoints with as little work as possible. Returns (status, reason)."""
452
+ status = "refreshed"
453
+ s.phase("explore")
454
+ try:
455
+ # 1. Stored samples that can be replayed as they are: no browser, no LLM.
456
+ preload(s, mem)
457
+ missing = missing_endpoints(s, mem)
458
+ s.emit(
459
+ Notice(
460
+ f"1. {len(mem.endpoints) - len(missing)} of {len(mem.endpoints)} endpoint(s) can "
461
+ "be re-checked from stored samples (no browser, no LLM)"
462
+ )
463
+ )
464
+ # 2. One plain visit for fresh traffic (fresh keys, cookies): still no LLM.
465
+ if missing:
466
+ labels = ", ".join(e.label for e in missing)
467
+ s.emit(Notice(f"2. Visiting the site once for fresh traffic: {labels}"))
468
+ s.visit(1)
469
+ missing = missing_endpoints(s, mem)
470
+ # 3. Only what's still missing needs the agent.
471
+ if missing and s.gateway is not None:
472
+ labels = ", ".join(e.label for e in missing)
473
+ s.emit(Notice(f"3. The agent looks for: {labels}"))
474
+ goal = f"{mem.goal}. Make the site send these requests again: {labels}"
475
+ result = s.explore(goal, max_steps, 5)
476
+ if result.status in ("bot-protection", "interrupted"):
477
+ status = result.status
478
+ missing = missing_endpoints(s, mem)
479
+ except KeyboardInterrupt:
480
+ return "interrupted", "stopped by user"
481
+ except Exception as exc:
482
+ s.emit(Notice(friendly_error(exc), "error", f"error code: {error_code(exc)}"))
483
+ return status, friendly_error(exc)
484
+ if missing:
485
+ names = ", ".join(e.label for e in missing)
486
+ if status == "refreshed" and s.gateway is not None:
487
+ # Looked for with every tool we have and still not seen: the site dropped them.
488
+ # They stay in the history but leave the client, report and runtime.
489
+ s.store.mark_gone(mem.domain, [e.key for e in missing], s.run_id)
490
+ return status, f"{len(missing)} known endpoint(s) not seen again: {names}"
491
+ return status, "all known endpoints re-captured"
492
+
493
+
494
+ def preload(s: Session, mem: DomainMemory, per_endpoint: int = 5) -> None:
495
+ """Load each known endpoint's latest stored samples, rebuilt for replay where possible.
496
+
497
+ They go straight into the recorder's lists (not re-saved: they're already stored).
498
+ """
499
+ stored = s.store.requests_for_domain(mem.domain)
500
+ known = {e.key: e for e in mem.endpoints}
501
+ picked: dict[str, list[CapturedRequest]] = {}
502
+ for key, req in zip(endpoint_keys(stored), stored, strict=False):
503
+ if key in known:
504
+ picked.setdefault(key, []).append(req)
505
+ for key, reqs in picked.items():
506
+ for req in reqs[-per_endpoint:]:
507
+ s.recorder.captured.append(req)
508
+ s.recorder.raw.append(rebuild_raw(req, known[key].required_headers))
509
+
510
+
511
+ def missing_endpoints(s: Session, mem: DomainMemory) -> list[Endpoint]:
512
+ """Known endpoints with no sample that can be replayed right now."""
513
+ ready = {
514
+ key
515
+ for key, raw in zip(endpoint_keys(s.recorder.captured), s.recorder.raw, strict=False)
516
+ if raw is not None and is_replayable(raw)
517
+ }
518
+ return [e for e in mem.endpoints if e.key not in ready]
519
+
520
+
521
+ # --- stages 2-8: test, generate, verify, learn, report ---------------------------------------
522
+
523
+
524
+ @dataclass
525
+ class FinishResult:
526
+ endpoints: list[Endpoint]
527
+ generated: GenerationResult | None
528
+ generation_error: str
529
+
530
+
531
+ def finish(s: Session, status: str, reason: str, started: float, opts: RunOptions) -> FinishResult:
532
+ store, site = s.store, s.site
533
+ reports: dict[str, ProbeReport] = {}
534
+ if not opts.no_probe and status not in STOPPED:
535
+ s.phase("test")
536
+ reports = probe_all(s)
537
+
538
+ endpoints = analyze(s.recorder.captured)
539
+ store.save_endpoints(site, s.run_id, endpoints)
540
+ for ep in endpoints:
541
+ report = reports.get(ep.key)
542
+ if report is not None:
543
+ apply_probe(ep, report)
544
+ store.save_probe_report(site, s.run_id, ep, report)
545
+ s.emit(EndpointsUpdated([endpoint_summary(e, site) for e in endpoints]))
546
+ exposed = exposures(endpoints, s.recorder.captured)
547
+ if exposed:
548
+ s.emit(
549
+ Notice(
550
+ f"Personal data: {summary_line(exposed)}. Ghost API hid these values.",
551
+ "warning",
552
+ "\n".join(f"{e.label}: {', '.join(e.kinds)}" for e in exposed),
553
+ )
554
+ )
555
+
556
+ generated: GenerationResult | None = None
557
+ generation_error = ""
558
+ if not opts.no_generate and endpoints and status not in ("interrupted", "bot-protection"):
559
+ s.phase("generate")
560
+ plan = plan_for(store, s.gateway, site, endpoints)
561
+ try:
562
+ if opts.no_verify:
563
+ generated = generate_from(
564
+ site, s.start_url, endpoints, s.recorder.captured, opts.out, plan, opts.lang,
565
+ store=store,
566
+ ) # fmt: skip
567
+ else:
568
+ s.phase("verify")
569
+ s.emit(Notice("Verifying the generated client against the live API..."))
570
+ verifier = Verifier(
571
+ site,
572
+ s.start_url,
573
+ endpoints,
574
+ s.recorder.captured,
575
+ opts.out,
576
+ raw=s.recorder.raw,
577
+ plan=plan,
578
+ replayer=s.replayer,
579
+ explore=s.replan if s.gateway is not None else None,
580
+ on_round=lambda r: s.emit(round_event(r)),
581
+ add_sample=s.recorder.add,
582
+ lang=opts.lang,
583
+ store=store,
584
+ )
585
+ verification = verifier.run()
586
+ generated = verification.result
587
+ endpoints = verifier.endpoints
588
+ store.save_endpoints(site, s.run_id, endpoints) # e.g. relaxed schemas
589
+ for ep in endpoints:
590
+ state, note = verification.statuses.get(ep.key, ("unverified", ""))
591
+ ep.status, ep.status_note = state, note
592
+ store.set_endpoint_status(site, ep, state, note)
593
+ s.emit(EndpointsUpdated([endpoint_summary(e, site) for e in endpoints]))
594
+ except (NothingToGenerate, GenerationError) as exc:
595
+ generation_error = str(exc)
596
+ s.emit(Notice(f"No client generated: {exc}", "warning"))
597
+ s.replayer.close()
598
+ if generated is not None:
599
+ version = drift.record(store, site, generated.spec, s.run_id)
600
+ generated.version = version
601
+ breaking = any(c.severity == "breaking" for c in version.changes)
602
+ level: Literal["info", "warning"] = "warning" if breaking else "info"
603
+ s.emit(
604
+ Notice(
605
+ f"Client version: {version.summary()}",
606
+ level,
607
+ "\n".join(c.line() for c in version.changes[:12]) if version.changes else "",
608
+ )
609
+ )
610
+ s.emit(client_event(generated))
611
+
612
+ # Workflow stage 8: remember what was learned.
613
+ s.phase("learn")
614
+ auto = discovery_lessons([(r.action, r.args, r.new_endpoints) for r in s.steps])
615
+ learned = store.add_lessons(site, s.run_id, auto, "auto")
616
+ if s.gateway is not None and len(s.steps) >= MIN_STEPS_FOR_LESSONS:
617
+ texts = write_lessons(
618
+ s.gateway, s.goal, status, [(r.action, r.args, r.result) for r in s.steps], endpoints
619
+ )
620
+ learned += store.add_lessons(site, s.run_id, texts, "llm")
621
+ store.finish_run(s.run_id, status)
622
+ if s.gateway is not None:
623
+ store.record_usage(s.run_id, s.gateway.usage.answered, s.gateway.usage.tokens)
624
+ s.emit(usage_event(s.gateway))
625
+ s.gateway.close()
626
+ s.emit(network_summary(s))
627
+ exports = write_exports(store, site, s.run_id, opts.out, generated.spec if generated else None)
628
+
629
+ s.emit(
630
+ RunFinished(
631
+ status=status,
632
+ reason=reason,
633
+ seconds=round(time.monotonic() - started, 1),
634
+ requests=len(s.recorder.captured),
635
+ endpoints=len(endpoints),
636
+ report=str(exports.report),
637
+ openapi=str(exports.openapi),
638
+ report_html=str(exports.html or ""),
639
+ lessons_learned=learned,
640
+ filtered=dict(s.recorder.dropped),
641
+ generation_error=generation_error,
642
+ exposure=summary_line(exposed) if exposed else "",
643
+ )
644
+ )
645
+ s.phase("done")
646
+ return FinishResult(endpoints, generated, generation_error)
647
+
648
+
649
+ def probe_all(s: Session) -> dict[str, ProbeReport]:
650
+ """Workflow stage 4: test every endpoint with direct calls."""
651
+ recorder = s.recorder
652
+ endpoints = analyze(recorder.captured)
653
+ if not endpoints:
654
+ return {}
655
+ s.emit(Notice(f"Testing {len(endpoints)} endpoint(s) with direct calls..."))
656
+ keys = endpoint_keys(recorder.captured)
657
+ raws = list(recorder.raw)
658
+ prober = Prober(s.replayer, on_sample=recorder.add)
659
+ reports: dict[str, ProbeReport] = {}
660
+ for ep in endpoints:
661
+ samples = [r for k, r in zip(keys, raws, strict=False) if k == ep.key and r]
662
+ report = prober.probe(ep, samples)
663
+ reports[ep.key] = report
664
+ label = display_label(ep, s.host)
665
+ for h in report.hypotheses:
666
+ s.emit(HypothesisTested(label, h.text, h.result, h.evidence))
667
+ if report.note:
668
+ s.emit(Notice(f"{label}: {report.note}"))
669
+ s.emit(ProbeFinished(len(endpoints), s.replayer.requests_made))
670
+ return reports
671
+
672
+
673
+ def apply_probe(ep: Endpoint, report: ProbeReport) -> None:
674
+ ep.replayable = report.replayable
675
+ ep.auth_type = report.auth_type
676
+ ep.pagination = report.pagination
677
+ ep.required_headers = report.required_headers
678
+
679
+
680
+ # --- event payloads --------------------------------------------------------------------------
681
+
682
+
683
+ def round_event(r: Round) -> VerifyRound:
684
+ counts = {k: sum(o.outcome == k for o in r.outcomes) for k in ("passed", "failed", "skipped")}
685
+ failures = []
686
+ for o in r.outcomes:
687
+ if o.outcome == "failed":
688
+ first = next((ln for ln in o.message.splitlines() if ln.strip()), "")
689
+ failures.append({"name": o.name, "message": first[:200]})
690
+ repairs = [{"method": x.method, "kind": x.kind, "action": x.action} for x in r.repairs]
691
+ return VerifyRound(
692
+ r.number, counts["passed"], counts["failed"], counts["skipped"], failures, repairs
693
+ )
694
+
695
+
696
+ def client_event(result: GenerationResult) -> ClientGenerated:
697
+ spec, client = result.spec, result.client
698
+ typescript = client.client_path.suffix == ".ts"
699
+ methods = []
700
+ for m in spec.methods:
701
+ if typescript:
702
+ name = camel(m.name)
703
+ args = [camel(p.name) for p in m.required]
704
+ iterator = f"iter{name[:1].upper()}{name[1:]}" if m.page_param else None
705
+ returns = ts_type(m.response_type)
706
+ else:
707
+ name, args = m.name, [p.name for p in m.required]
708
+ iterator = f"iter_{m.name}" if m.page_param else None
709
+ returns = m.response_type
710
+ methods.append(
711
+ {
712
+ "name": name,
713
+ "args": args,
714
+ "returns": returns,
715
+ "iterator": iterator,
716
+ "summary": m.summary,
717
+ "endpoint": m.endpoint_key,
718
+ }
719
+ )
720
+ files = list(
721
+ dict.fromkeys(p.name for p in (client.client_path, client.models_path, client.tests_path))
722
+ )
723
+ return ClientGenerated(
724
+ class_name=spec.class_name,
725
+ lang="typescript" if typescript else "python",
726
+ named_by_llm=result.named_by_llm,
727
+ directory=str(client.directory),
728
+ methods=methods,
729
+ credentials=[{"arg": c.arg, "env": c.env} for c in spec.credentials],
730
+ skipped=list(spec.skipped),
731
+ files=files,
732
+ )
733
+
734
+
735
+ def usage_event(gateway: LLMGateway) -> UsageUpdated:
736
+ u = gateway.usage
737
+ return UsageUpdated(
738
+ answered=u.answered,
739
+ tokens=u.tokens,
740
+ cache_hits=u.cache_hits,
741
+ retries=u.retries,
742
+ fallbacks=u.fallbacks,
743
+ by_provider=dict(u.by_provider),
744
+ problems=[f"{n}x {p}" for p, n in u.problems.most_common(5)],
745
+ )