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.
- ghost_api/__init__.py +3 -0
- ghost_api/__main__.py +5 -0
- ghost_api/adopt.py +173 -0
- ghost_api/agent/__init__.py +0 -0
- ghost_api/agent/orchestrator.py +413 -0
- ghost_api/agent/prompts.py +149 -0
- ghost_api/agent/tools.py +84 -0
- ghost_api/analysis/__init__.py +0 -0
- ghost_api/analysis/analyzer.py +130 -0
- ghost_api/analysis/detectors.py +188 -0
- ghost_api/analysis/exposure.py +80 -0
- ghost_api/analysis/graphql.py +67 -0
- ghost_api/analysis/models.py +64 -0
- ghost_api/analysis/schema.py +109 -0
- ghost_api/analysis/url_grouping.py +217 -0
- ghost_api/api.py +264 -0
- ghost_api/audit.py +78 -0
- ghost_api/browser/__init__.py +0 -0
- ghost_api/browser/auth_state.py +172 -0
- ghost_api/browser/driver.py +273 -0
- ghost_api/browser/egress.py +183 -0
- ghost_api/browser/gate.py +127 -0
- ghost_api/browser/install.py +28 -0
- ghost_api/browser/snapshot.py +221 -0
- ghost_api/capture/__init__.py +0 -0
- ghost_api/capture/filters.py +255 -0
- ghost_api/capture/har.py +148 -0
- ghost_api/capture/models.py +41 -0
- ghost_api/capture/personal.py +188 -0
- ghost_api/capture/recorder.py +181 -0
- ghost_api/cli/__init__.py +44 -0
- ghost_api/cli/_common.py +219 -0
- ghost_api/cli/client.py +487 -0
- ghost_api/cli/data.py +492 -0
- ghost_api/cli/explore.py +379 -0
- ghost_api/cli/manage.py +437 -0
- ghost_api/config.py +132 -0
- ghost_api/drift.py +344 -0
- ghost_api/drive.py +211 -0
- ghost_api/errors.py +106 -0
- ghost_api/events.py +213 -0
- ghost_api/exitcodes.py +21 -0
- ghost_api/export/__init__.py +71 -0
- ghost_api/export/confidence.py +45 -0
- ghost_api/export/html.py +41 -0
- ghost_api/export/openapi.py +201 -0
- ghost_api/export/report.py +227 -0
- ghost_api/export/site_data.py +196 -0
- ghost_api/export/tabular.py +88 -0
- ghost_api/export/viewer/report.html +286 -0
- ghost_api/generator/__init__.py +221 -0
- ghost_api/generator/cassettes.py +80 -0
- ghost_api/generator/models_gen.py +144 -0
- ghost_api/generator/naming.py +174 -0
- ghost_api/generator/package.py +197 -0
- ghost_api/generator/python_gen.py +172 -0
- ghost_api/generator/spec.py +486 -0
- ghost_api/generator/templates/client.py.j2 +518 -0
- ghost_api/generator/templates/client.test.ts.j2 +106 -0
- ghost_api/generator/templates/client.ts.j2 +339 -0
- ghost_api/generator/templates/models.py.j2 +45 -0
- ghost_api/generator/templates/package_readme.md.j2 +111 -0
- ghost_api/generator/templates/pyproject.toml.j2 +22 -0
- ghost_api/generator/templates/test_client.py.j2 +105 -0
- ghost_api/generator/ts_gen.py +232 -0
- ghost_api/integrations/__init__.py +0 -0
- ghost_api/integrations/claude_code/commands/ghost-data.md +10 -0
- ghost_api/integrations/claude_code/commands/ghost-diff.md +10 -0
- ghost_api/integrations/claude_code/commands/ghost-explore.md +10 -0
- ghost_api/integrations/claude_code/skills/ghost-api/SKILL.md +46 -0
- ghost_api/integrations/setup.py +84 -0
- ghost_api/llm/__init__.py +0 -0
- ghost_api/llm/doctor.py +60 -0
- ghost_api/llm/gateway.py +476 -0
- ghost_api/llm/providers.py +124 -0
- ghost_api/memory/__init__.py +0 -0
- ghost_api/memory/knowledge.py +157 -0
- ghost_api/memory/store.py +883 -0
- ghost_api/net.py +235 -0
- ghost_api/netguard.py +203 -0
- ghost_api/pipeline.py +745 -0
- ghost_api/probe/__init__.py +0 -0
- ghost_api/probe/prober.py +311 -0
- ghost_api/probe/replay.py +313 -0
- ghost_api/project.py +186 -0
- ghost_api/py.typed +0 -0
- ghost_api/runtime.py +397 -0
- ghost_api/serve/__init__.py +101 -0
- ghost_api/serve/explorer_mcp.py +305 -0
- ghost_api/serve/mcp.py +121 -0
- ghost_api/serve/mock.py +130 -0
- ghost_api/serve/rest.py +132 -0
- ghost_api/site.py +84 -0
- ghost_api/specdiff.py +122 -0
- ghost_api/ui/__init__.py +0 -0
- ghost_api/ui/keys.py +134 -0
- ghost_api/ui/repl.py +453 -0
- ghost_api/ui/terminal.py +573 -0
- ghost_api/verify/__init__.py +0 -0
- ghost_api/verify/classify.py +84 -0
- ghost_api/verify/repair.py +40 -0
- ghost_api/verify/runner.py +110 -0
- ghost_api/verify/verifier.py +291 -0
- ghost_api/watch.py +313 -0
- ghost_api/web/__init__.py +1 -0
- ghost_api/web/server.py +690 -0
- ghost_api/web/static/assets/Endpoints-zPnjlcM8.js +46 -0
- ghost_api/web/static/assets/Run-D85zWHey.js +91 -0
- ghost_api/web/static/assets/Settings-D0aWHFAt.js +11 -0
- ghost_api/web/static/assets/Site-a88shEmP.js +71 -0
- ghost_api/web/static/assets/Watches-CqZWIa9G.js +16 -0
- ghost_api/web/static/assets/circle-x-BcTE_1tr.js +11 -0
- ghost_api/web/static/assets/index-Ds2e5B9f.js +141 -0
- ghost_api/web/static/assets/index-xQpyIBbG.css +1 -0
- ghost_api/web/static/assets/minus-C2xqP1eQ.js +6 -0
- ghost_api/web/static/assets/refresh-cw-CxdR_8Oi.js +6 -0
- ghost_api/web/static/favicon.svg +1 -0
- ghost_api/web/static/index.html +26 -0
- ghost_api/workflows.py +203 -0
- ghost_api_cli-0.2.0.dist-info/METADATA +1019 -0
- ghost_api_cli-0.2.0.dist-info/RECORD +125 -0
- ghost_api_cli-0.2.0.dist-info/WHEEL +5 -0
- ghost_api_cli-0.2.0.dist-info/entry_points.txt +2 -0
- ghost_api_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- ghost_api_cli-0.2.0.dist-info/top_level.txt +1 -0
ghost_api/cli/explore.py
ADDED
|
@@ -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}.")
|