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
@@ -0,0 +1,379 @@
1
+ """Commands that explore sites: run, import, refresh, login, logout."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Annotated
9
+ from urllib.parse import urlsplit
10
+
11
+ import typer
12
+
13
+ from ghost_api import exitcodes, workflows
14
+ from ghost_api.cli._common import (
15
+ SITE_HELP,
16
+ _check_lang,
17
+ _need_db,
18
+ _normalize_url,
19
+ _not_allowed,
20
+ _options,
21
+ _renderer,
22
+ _RunResult,
23
+ _site,
24
+ app,
25
+ console,
26
+ json_out,
27
+ )
28
+ from ghost_api.config import (
29
+ Settings,
30
+ default_db_path,
31
+ )
32
+ from ghost_api.events import (
33
+ EventBus,
34
+ Notice,
35
+ )
36
+ from ghost_api.memory.store import Store
37
+ from ghost_api.net import RateLimiter
38
+ from ghost_api.pipeline import (
39
+ RunOptions,
40
+ finish,
41
+ open_session,
42
+ robots_policy,
43
+ )
44
+ from ghost_api.site import site_key
45
+ from ghost_api.ui import keys
46
+
47
+
48
+ @app.command()
49
+ def run(
50
+ url: Annotated[str, typer.Argument(help="Website to explore, e.g. https://example.com")],
51
+ goal: Annotated[str, typer.Option("--goal", "-g", help="What the client should do.")],
52
+ lang: Annotated[str, typer.Option(help="Client language: python or typescript.")] = "python",
53
+ headed: Annotated[bool, typer.Option("--headed", help="Show the browser window.")] = False,
54
+ max_steps: Annotated[int, typer.Option(help="Maximum agent actions.")] = 40,
55
+ max_llm_calls: Annotated[int, typer.Option(help="Maximum LLM calls.")] = 50,
56
+ max_minutes: Annotated[float, typer.Option(help="Time limit in minutes.")] = 15.0,
57
+ rate_limit: Annotated[
58
+ float, typer.Option(help="Max requests per second the tool sends itself.")
59
+ ] = 1.0,
60
+ ignore_robots: Annotated[
61
+ bool, typer.Option("--ignore-robots", help="Skip the robots.txt check.")
62
+ ] = False,
63
+ db: Annotated[Path | None, typer.Option(help="SQLite database path.")] = None,
64
+ no_agent: Annotated[
65
+ bool, typer.Option("--no-agent", help="Don't use the LLM; just load and scroll the page.")
66
+ ] = False,
67
+ no_probe: Annotated[
68
+ bool, typer.Option("--no-probe", help="Don't test endpoints by calling them directly.")
69
+ ] = False,
70
+ max_probes: Annotated[
71
+ int, typer.Option(help="Maximum direct API calls for testing endpoints.")
72
+ ] = 60,
73
+ no_generate: Annotated[
74
+ bool, typer.Option("--no-generate", help="Don't generate a client.")
75
+ ] = False,
76
+ no_verify: Annotated[
77
+ bool, typer.Option("--no-verify", help="Don't run the generated tests against the API.")
78
+ ] = False,
79
+ third_party_robots: Annotated[
80
+ bool,
81
+ typer.Option(
82
+ "--third-party-robots",
83
+ help="Also check the page's API calls to other sites against their robots.txt.",
84
+ ),
85
+ ] = False,
86
+ out: Annotated[Path, typer.Option(help="Folder for generated clients.")] = Path("output"),
87
+ ) -> None:
88
+ """Explore a website with the AI agent and map its hidden API."""
89
+ console.print(f"[dim]⏳ Starting run on {url}...[/]")
90
+ try:
91
+ settings = Settings(
92
+ url=_normalize_url(url),
93
+ goal=goal,
94
+ lang=lang,
95
+ headed=headed,
96
+ max_steps=max_steps,
97
+ max_llm_calls=max_llm_calls,
98
+ max_minutes=max_minutes,
99
+ rate_limit=rate_limit,
100
+ ignore_robots=ignore_robots,
101
+ db_path=db or default_db_path(),
102
+ )
103
+ except ValueError as exc:
104
+ raise typer.BadParameter(str(exc)) from exc
105
+ opts = RunOptions(
106
+ lang=settings.lang,
107
+ headed=settings.headed,
108
+ max_steps=settings.max_steps,
109
+ max_minutes=settings.max_minutes,
110
+ max_probes=max_probes,
111
+ no_probe=no_probe,
112
+ no_generate=no_generate,
113
+ no_verify=no_verify,
114
+ out=out,
115
+ third_party_robots=third_party_robots,
116
+ )
117
+ bus = EventBus()
118
+ renderer = _renderer()
119
+ bus.subscribe(renderer.handle)
120
+ result = _RunResult()
121
+ bus.subscribe(result)
122
+ ask = renderer.ask if settings.headed and sys.stdin.isatty() else None
123
+ try:
124
+ workflows.run(
125
+ settings, opts, bus, use_agent=not no_agent, ask_user=ask,
126
+ stop=keys.stop_requested, started_hook=renderer.start,
127
+ ) # fmt: skip
128
+ except workflows.NotAllowed as exc:
129
+ _not_allowed(settings.url, str(exc))
130
+ except workflows.NoLLMKey as exc:
131
+ console.print(f"[red]{exc}[/]")
132
+ raise typer.Exit(code=exitcodes.USAGE) from exc
133
+ finally:
134
+ renderer.close()
135
+ console.print(f"[dim]Saved to {settings.db_path}[/]")
136
+ result.finish(settings.db_path)
137
+
138
+
139
+ @app.command("import")
140
+ def import_har(
141
+ har: Annotated[Path, typer.Argument(help="A .har file saved from the browser's DevTools.")],
142
+ goal: Annotated[
143
+ str, typer.Option("--goal", "-g", help="What you were doing (for the report).")
144
+ ] = "imported from a HAR file",
145
+ url: Annotated[
146
+ str | None, typer.Option(help="The site's start URL (default: the first page in the file).")
147
+ ] = None,
148
+ offline: Annotated[
149
+ bool,
150
+ typer.Option(
151
+ "--offline", help="Send nothing to the site: no endpoint tests, no client tests."
152
+ ),
153
+ ] = False,
154
+ lang: Annotated[str, typer.Option(help="Client language: python or typescript.")] = "python",
155
+ rate_limit: Annotated[
156
+ float, typer.Option(help="Max requests per second the tool sends itself.")
157
+ ] = 1.0,
158
+ max_probes: Annotated[
159
+ int, typer.Option(help="Maximum direct API calls for testing endpoints.")
160
+ ] = 60,
161
+ out: Annotated[Path, typer.Option(help="Folder for generated clients.")] = Path("output"),
162
+ db: Annotated[Path | None, typer.Option(help="SQLite database path.")] = None,
163
+ ) -> None:
164
+ """Map a site's API from a HAR file you recorded in your browser (no AI, no browser).
165
+
166
+ For sites behind a login or a bot check: browse them yourself with DevTools open
167
+ (Network tab), "Save all as HAR", then import. Cookies, tokens and personal data are
168
+ removed before anything is stored. Delete the .har file afterwards: it holds them.
169
+ """
170
+ from ghost_api.capture.har import HarError, read_har, summary
171
+ from ghost_api.net import RobotsPolicy
172
+
173
+ lang = _check_lang(lang)
174
+ try:
175
+ recording = read_har(har, _normalize_url(url) if url else None)
176
+ except (HarError, OSError) as exc:
177
+ console.print(f"[red]{exc}[/]")
178
+ raise typer.Exit(code=exitcodes.USAGE) from exc
179
+ for line in summary(recording):
180
+ console.print(f"[dim]{line}[/]")
181
+ try: # the project's rules (terms checked, denied paths) apply to imports too
182
+ policy = workflows.enforce(site_key(recording.start_url) or recording.target_host, False)
183
+ except workflows.NotAllowed as exc:
184
+ _not_allowed(recording.start_url, str(exc))
185
+ return
186
+ if policy.deny_paths:
187
+ denied = sum(1 for c, _ in recording.captured if policy.denies(urlsplit(c.url).path))
188
+ recording.captured = [(c, r) for c, r in recording.captured
189
+ if not policy.denies(urlsplit(c.url).path)] # fmt: skip
190
+ if denied:
191
+ console.print(
192
+ f"[dim]{denied} recorded call(s) left out: denied by the project rules[/]"
193
+ )
194
+ path = db or default_db_path()
195
+ bus = EventBus()
196
+ renderer = _renderer()
197
+ bus.subscribe(renderer.handle)
198
+ result = _RunResult()
199
+ bus.subscribe(result)
200
+ limiter = RateLimiter(rate_limit)
201
+ host = recording.target_host
202
+ if offline:
203
+ robots = RobotsPolicy.allow_all(host, "offline import: nothing is sent")
204
+ else:
205
+ robots, allowed = robots_policy(recording.start_url, False, limiter, bus)
206
+ if not allowed: # the recording is still mapped, just never replayed
207
+ offline = True
208
+ bus.emit(Notice("Importing without testing endpoints (robots.txt)", "warning"))
209
+ opts = RunOptions(lang=lang, max_probes=max_probes, no_probe=offline, no_verify=offline,
210
+ out=out) # fmt: skip
211
+ with Store(path) as store:
212
+ renderer.start()
213
+ try:
214
+ s = open_session(
215
+ store, bus, host, recording.start_url, goal, False, limiter, robots, None,
216
+ max_probes, screens_root=path, lang=lang,
217
+ ) # fmt: skip
218
+ started = time.monotonic()
219
+ s.phase("explore")
220
+ for captured, raw in recording.captured:
221
+ s.recorder.add(captured, raw)
222
+ s.recorder.dropped.update(recording.dropped)
223
+ reason = f"imported {len(recording.captured)} API calls from {har.name}"
224
+ finish(s, "captured", reason, started, opts)
225
+ finally:
226
+ renderer.close()
227
+ console.print(f"[dim]Saved to {path}. You can delete {har.name} now.[/]")
228
+ result.finish(path)
229
+
230
+
231
+ @app.command()
232
+ def refresh(
233
+ domain: Annotated[str, typer.Argument(help=SITE_HELP)],
234
+ headed: Annotated[bool, typer.Option("--headed", help="Show the browser window.")] = False,
235
+ max_steps: Annotated[int, typer.Option(help="Agent steps, if the agent is needed.")] = 10,
236
+ max_llm_calls: Annotated[int, typer.Option(help="Maximum LLM calls.")] = 20,
237
+ rate_limit: Annotated[
238
+ float, typer.Option(help="Max requests per second the tool sends itself.")
239
+ ] = 1.0,
240
+ ignore_robots: Annotated[
241
+ bool, typer.Option("--ignore-robots", help="Skip the robots.txt check.")
242
+ ] = False,
243
+ max_probes: Annotated[
244
+ int, typer.Option(help="Maximum direct API calls for testing endpoints.")
245
+ ] = 60,
246
+ no_verify: Annotated[
247
+ bool, typer.Option("--no-verify", help="Don't run the generated tests against the API.")
248
+ ] = False,
249
+ out: Annotated[Path, typer.Option(help="Folder for generated clients.")] = Path("output"),
250
+ no_llm: Annotated[
251
+ bool, typer.Option("--no-llm", help="Never call the LLM (no agent, URL-based names).")
252
+ ] = False,
253
+ third_party_robots: Annotated[
254
+ bool,
255
+ typer.Option(
256
+ "--third-party-robots",
257
+ help="Also check the page's API calls to other sites against their robots.txt.",
258
+ ),
259
+ ] = False,
260
+ lang: Annotated[str, typer.Option(help="Client language: python or typescript.")] = "python",
261
+ db: Annotated[Path | None, typer.Option(help="SQLite database path.")] = None,
262
+ ) -> None:
263
+ """Re-check a known site using memory: replay first, browse only if needed.
264
+
265
+ Also continues an interrupted run, since its traffic and endpoints were saved.
266
+ """
267
+ console.print(f"[dim]⏳ Starting refresh for {domain}...[/]")
268
+ path = _need_db(db)
269
+ lang = _check_lang(lang)
270
+ with Store(path) as store:
271
+ domain = _site(store, domain)
272
+ opts = RunOptions(lang=lang, headed=headed, max_steps=max_steps, max_probes=max_probes,
273
+ no_verify=no_verify, out=out,
274
+ third_party_robots=third_party_robots) # fmt: skip
275
+ bus = EventBus()
276
+ renderer = _renderer()
277
+ bus.subscribe(renderer.handle)
278
+ result = _RunResult()
279
+ bus.subscribe(result)
280
+ try:
281
+ _, now, first = workflows.refresh(
282
+ path, domain, opts, bus, use_llm=not no_llm, max_llm_calls=max_llm_calls,
283
+ rate_limit=rate_limit, ignore_robots=ignore_robots, stop=keys.stop_requested,
284
+ started_hook=renderer.start,
285
+ ) # fmt: skip
286
+ except workflows.NothingRemembered as exc:
287
+ console.print(str(exc))
288
+ raise typer.Exit(code=exitcodes.FAILED) from exc
289
+ except workflows.NotAllowed as exc:
290
+ _not_allowed(domain, str(exc))
291
+ finally:
292
+ renderer.close()
293
+ if first:
294
+ saved = 100 * (first - now) / first
295
+ console.print(
296
+ f"[bold]Memory:[/] {now} LLM call(s) vs {first} on the first run ({saved:.0f}% fewer)"
297
+ )
298
+ result.data["llm_calls"] = now
299
+ result.finish(path)
300
+
301
+
302
+ @app.command()
303
+ def login(
304
+ url: Annotated[
305
+ str | None, typer.Argument(help="The site's page to log in on, e.g. https://example.com")
306
+ ] = None,
307
+ list_saved: Annotated[bool, typer.Option("--list", help="Show saved logins.")] = False,
308
+ db: Annotated[Path | None, typer.Option(help="SQLite database path.")] = None,
309
+ ) -> None:
310
+ """Log in to a site yourself, once; Ghost API reuses the session.
311
+
312
+ A browser window opens: log in as you normally would (Ghost API never sees your
313
+ password), then press Enter here. Runs and refreshes then start logged in, and fetch,
314
+ watch and serve send the session's cookies. Remove it with `ghost-api logout SITE`.
315
+ """
316
+ from ghost_api.browser import auth_state
317
+ from ghost_api.browser.driver import ActionError, BrowserSession
318
+ from ghost_api.capture.recorder import TrafficRecorder
319
+ from ghost_api.ui import keys
320
+
321
+ path = db or default_db_path()
322
+ if list_saved or url is None:
323
+ saved = auth_state.saved_sites(path)
324
+ if _options["json"]:
325
+ json_out({"logins": saved})
326
+ elif not saved:
327
+ console.print("No saved logins. Save one with: ghost-api login https://example.com")
328
+ for meta in [] if _options["json"] else saved:
329
+ age = auth_state.age_days(meta)
330
+ how = "encrypted" if meta.get("encrypted") else "owner-only file"
331
+ console.print(f" {meta['site']}: saved {age:.0f} day(s) ago, "
332
+ f"{meta.get('cookies', '?')} cookies, {how}") # fmt: skip
333
+ return
334
+ url = _normalize_url(url)
335
+ site = site_key(url) or (urlsplit(url).hostname or "")
336
+ host = urlsplit(url).hostname or ""
337
+ console.print(f"Opening {url}. Log in in the browser window, then come back here.")
338
+ with BrowserSession(url, TrafficRecorder(host), headed=True) as browser:
339
+ try:
340
+ browser.goto(url)
341
+ except ActionError as exc:
342
+ console.print(f"[red]{exc}[/]")
343
+ raise typer.Exit(code=exitcodes.FAILED) from exc
344
+ with keys.input_paused():
345
+ typer.prompt("Press Enter when you are logged in", default="", show_default=False)
346
+ state = browser.export_state()
347
+ meta = auth_state.save(path, site, state)
348
+ if _options["json"]:
349
+ json_out(meta)
350
+ return
351
+ how = (
352
+ "encrypted for your Windows account"
353
+ if meta["encrypted"]
354
+ else "in a file only your user can read (not encrypted)"
355
+ )
356
+ console.print(
357
+ f"[green]Saved the login for {site}[/] ({meta['cookies']} cookies, {how}). "
358
+ f"Remove it with: ghost-api logout {site}"
359
+ )
360
+
361
+
362
+ @app.command()
363
+ def logout(
364
+ domain: Annotated[str, typer.Argument(help=SITE_HELP)],
365
+ db: Annotated[Path | None, typer.Option(help="SQLite database path.")] = None,
366
+ ) -> None:
367
+ """Delete a site's saved login."""
368
+ from ghost_api.browser import auth_state
369
+
370
+ path = db or default_db_path()
371
+ names = [m["site"] for m in auth_state.saved_sites(path)]
372
+ site = domain if domain in names else next((n for n in names if n.startswith(domain)), domain)
373
+ removed = auth_state.delete(path, site)
374
+ if _options["json"]:
375
+ json_out({"site": site, "removed": removed})
376
+ elif removed:
377
+ console.print(f"Deleted the saved login for {site}.")
378
+ else:
379
+ console.print(f"No saved login for {domain}.")