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/client.py
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
"""Commands about the generated client: generate, verify, report, diff, mock."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated, Any
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
from ghost_api import exitcodes
|
|
15
|
+
from ghost_api.cli._common import (
|
|
16
|
+
SITE_HELP,
|
|
17
|
+
_check_lang,
|
|
18
|
+
_need_db,
|
|
19
|
+
_options,
|
|
20
|
+
_site,
|
|
21
|
+
app,
|
|
22
|
+
console,
|
|
23
|
+
json_out,
|
|
24
|
+
)
|
|
25
|
+
from ghost_api.config import (
|
|
26
|
+
default_db_path,
|
|
27
|
+
state_dir,
|
|
28
|
+
)
|
|
29
|
+
from ghost_api.export import write_exports
|
|
30
|
+
from ghost_api.export.site_data import endpoint_summary
|
|
31
|
+
from ghost_api.generator import NothingToGenerate, generate_client
|
|
32
|
+
from ghost_api.generator.python_gen import GenerationError
|
|
33
|
+
from ghost_api.llm.gateway import LLMGateway
|
|
34
|
+
from ghost_api.llm.providers import default_providers
|
|
35
|
+
from ghost_api.memory.store import Store
|
|
36
|
+
from ghost_api.pipeline import (
|
|
37
|
+
client_event,
|
|
38
|
+
round_event,
|
|
39
|
+
)
|
|
40
|
+
from ghost_api.ui.terminal import (
|
|
41
|
+
ACCENT,
|
|
42
|
+
TerminalRenderer,
|
|
43
|
+
endpoint_table,
|
|
44
|
+
file_link,
|
|
45
|
+
print_client,
|
|
46
|
+
)
|
|
47
|
+
from ghost_api.verify.verifier import Verifier
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@app.command()
|
|
51
|
+
def generate(
|
|
52
|
+
domain: Annotated[str, typer.Argument(help=SITE_HELP)],
|
|
53
|
+
out: Annotated[Path, typer.Option(help="Folder for generated clients.")] = Path("output"),
|
|
54
|
+
no_llm: Annotated[
|
|
55
|
+
bool, typer.Option("--no-llm", help="Use names derived from URLs (no LLM call).")
|
|
56
|
+
] = False,
|
|
57
|
+
lang: Annotated[str, typer.Option(help="Client language: python or typescript.")] = "python",
|
|
58
|
+
db: Annotated[Path | None, typer.Option(help="SQLite database path.")] = None,
|
|
59
|
+
allow: Annotated[
|
|
60
|
+
list[str] | None,
|
|
61
|
+
typer.Option(
|
|
62
|
+
"--allow",
|
|
63
|
+
help='An endpoint you checked only reads data, e.g. "POST /api/search". Ghost API '
|
|
64
|
+
"skips POSTs without a read word in their URL; this includes one anyway. Repeatable.",
|
|
65
|
+
),
|
|
66
|
+
] = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""(Re)generate the client for a site from its stored endpoint map."""
|
|
69
|
+
path = _need_db(db)
|
|
70
|
+
gateway = (
|
|
71
|
+
None
|
|
72
|
+
if no_llm
|
|
73
|
+
else LLMGateway(
|
|
74
|
+
default_providers(), max_calls=3, cooldown_file=state_dir() / "cooldowns.json"
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
if gateway is not None and not gateway.providers:
|
|
78
|
+
gateway = None
|
|
79
|
+
try:
|
|
80
|
+
with Store(path) as store:
|
|
81
|
+
domain = _site(store, domain)
|
|
82
|
+
if gateway is not None:
|
|
83
|
+
gateway.store = store
|
|
84
|
+
result = generate_client(
|
|
85
|
+
store, domain, out, llm=gateway, lang=_check_lang(lang), allow=set(allow or [])
|
|
86
|
+
)
|
|
87
|
+
except (NothingToGenerate, GenerationError) as exc:
|
|
88
|
+
console.print(f"[red]No client generated:[/] {exc}")
|
|
89
|
+
raise typer.Exit(code=1) from exc
|
|
90
|
+
finally:
|
|
91
|
+
if gateway is not None:
|
|
92
|
+
gateway.close()
|
|
93
|
+
event = client_event(result)
|
|
94
|
+
print_client(console, event)
|
|
95
|
+
if _options["json"]:
|
|
96
|
+
json_out({"site": domain, "client": event.to_dict()})
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@app.command()
|
|
100
|
+
def verify(
|
|
101
|
+
domain: Annotated[str, typer.Argument(help=SITE_HELP)],
|
|
102
|
+
out: Annotated[Path, typer.Option(help="Folder for generated clients.")] = Path("output"),
|
|
103
|
+
lang: Annotated[str, typer.Option(help="Client language: python or typescript.")] = "python",
|
|
104
|
+
db: Annotated[Path | None, typer.Option(help="SQLite database path.")] = None,
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Regenerate a site's client, run its tests against the live API, and repair.
|
|
107
|
+
|
|
108
|
+
Uses stored data only. Endpoints that need a key are tested when the key is set in
|
|
109
|
+
the environment (the generated client says which variable).
|
|
110
|
+
"""
|
|
111
|
+
console.print(f"[dim]⏳ Starting verification for {domain}...[/]")
|
|
112
|
+
started = time.monotonic()
|
|
113
|
+
path = _need_db(db)
|
|
114
|
+
renderer = TerminalRenderer(console, plain=True)
|
|
115
|
+
with Store(path) as store:
|
|
116
|
+
domain = _site(store, domain)
|
|
117
|
+
run_id = store.latest_endpoint_run(domain)
|
|
118
|
+
run_row = store.latest_run(domain)
|
|
119
|
+
if run_id is None or run_row is None:
|
|
120
|
+
console.print(f"No endpoints stored for {domain}. Run `ghost-api run` first.")
|
|
121
|
+
raise typer.Exit(code=1)
|
|
122
|
+
endpoints = store.current_endpoints(domain)
|
|
123
|
+
console.print(f"[bold]Verifying the {domain} client against the live API...[/]")
|
|
124
|
+
verifier = Verifier(
|
|
125
|
+
domain,
|
|
126
|
+
run_row.start_url,
|
|
127
|
+
endpoints,
|
|
128
|
+
store.requests_for_domain(domain),
|
|
129
|
+
out,
|
|
130
|
+
on_round=lambda r: renderer.handle(round_event(r)),
|
|
131
|
+
lang=_check_lang(lang),
|
|
132
|
+
)
|
|
133
|
+
try:
|
|
134
|
+
report = verifier.run()
|
|
135
|
+
except (NothingToGenerate, GenerationError) as exc:
|
|
136
|
+
console.print(f"[red]No client generated:[/] {exc}")
|
|
137
|
+
raise typer.Exit(code=1) from exc
|
|
138
|
+
store.save_endpoints(domain, run_id, verifier.endpoints)
|
|
139
|
+
for ep in verifier.endpoints:
|
|
140
|
+
state, note = report.statuses.get(ep.key, ("unverified", ""))
|
|
141
|
+
ep.status, ep.status_note = state, note
|
|
142
|
+
store.set_endpoint_status(domain, ep, state, note)
|
|
143
|
+
summaries = [endpoint_summary(e, domain) for e in verifier.endpoints]
|
|
144
|
+
console.print(endpoint_table(summaries, f"Endpoints ({len(summaries)})"))
|
|
145
|
+
if report.result is not None:
|
|
146
|
+
print_client(console, client_event(report.result))
|
|
147
|
+
elapsed = time.monotonic() - started
|
|
148
|
+
console.print(f"[dim]Verification completed in {elapsed:.1f}s[/]")
|
|
149
|
+
failed = any(state == "failed" for state, _ in report.statuses.values())
|
|
150
|
+
if _options["json"]:
|
|
151
|
+
client = client_event(report.result).to_dict() if report.result is not None else None
|
|
152
|
+
json_out(
|
|
153
|
+
{"site": domain, "passed": not failed, "seconds": round(elapsed, 1),
|
|
154
|
+
"endpoints": summaries, "client": client}
|
|
155
|
+
) # fmt: skip
|
|
156
|
+
if failed:
|
|
157
|
+
raise typer.Exit(code=exitcodes.FAILED)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@app.command()
|
|
161
|
+
def report(
|
|
162
|
+
domain: Annotated[str, typer.Argument(help=SITE_HELP)],
|
|
163
|
+
run_id: Annotated[
|
|
164
|
+
int | None, typer.Option("--run", help="Run to report on (default: the latest).")
|
|
165
|
+
] = None,
|
|
166
|
+
out: Annotated[Path, typer.Option(help="Folder for generated files.")] = Path("output"),
|
|
167
|
+
db: Annotated[Path | None, typer.Option(help="SQLite database path.")] = None,
|
|
168
|
+
) -> None:
|
|
169
|
+
"""Write report.md, report.html and openapi.yaml for a site from what is stored."""
|
|
170
|
+
path = _need_db(db)
|
|
171
|
+
with Store(path) as store:
|
|
172
|
+
domain = _site(store, domain)
|
|
173
|
+
run_row = store.get_run(run_id) if run_id else store.latest_run(domain)
|
|
174
|
+
if run_row is None or run_row.domain != domain:
|
|
175
|
+
console.print(f"No run found for {domain}.")
|
|
176
|
+
raise typer.Exit(code=1)
|
|
177
|
+
exports = write_exports(store, domain, run_row.id, out)
|
|
178
|
+
console.print(Text("Report: ") + file_link(exports.report))
|
|
179
|
+
if exports.html:
|
|
180
|
+
console.print(Text("Report (shareable page): ") + file_link(exports.html))
|
|
181
|
+
console.print(f"OpenAPI: {exports.openapi}")
|
|
182
|
+
if _options["json"]:
|
|
183
|
+
json_out(
|
|
184
|
+
{"site": domain, "run_id": run_row.id, "report": str(exports.report),
|
|
185
|
+
"report_html": str(exports.html or ""), "openapi": str(exports.openapi)}
|
|
186
|
+
) # fmt: skip
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@app.command()
|
|
190
|
+
def diff(
|
|
191
|
+
domain: Annotated[str, typer.Argument(help=SITE_HELP)],
|
|
192
|
+
live: Annotated[
|
|
193
|
+
bool,
|
|
194
|
+
typer.Option(
|
|
195
|
+
"--live",
|
|
196
|
+
help="Also call each endpoint once and compare the reply with what was captured "
|
|
197
|
+
"(no browser).",
|
|
198
|
+
),
|
|
199
|
+
] = False,
|
|
200
|
+
history: Annotated[bool, typer.Option("--history", help="List the client's versions.")] = False,
|
|
201
|
+
since: Annotated[
|
|
202
|
+
str | None,
|
|
203
|
+
typer.Option(help="Changes recorded after this client version (e.g. after a refresh)."),
|
|
204
|
+
] = None,
|
|
205
|
+
markdown: Annotated[
|
|
206
|
+
bool, typer.Option("--markdown", help="Print a pull-request description.")
|
|
207
|
+
] = False,
|
|
208
|
+
fail_on: Annotated[
|
|
209
|
+
str, typer.Option(help="Exit 1 on: breaking, any or never (for CI).")
|
|
210
|
+
] = "never",
|
|
211
|
+
db: Annotated[Path | None, typer.Option(help="SQLite database path.")] = None,
|
|
212
|
+
) -> None:
|
|
213
|
+
"""What changed in a site's API since the last client version, and what it means."""
|
|
214
|
+
from ghost_api import drift
|
|
215
|
+
from ghost_api.generator import site_spec
|
|
216
|
+
|
|
217
|
+
if fail_on not in ("breaking", "any", "never"):
|
|
218
|
+
raise typer.BadParameter("--fail-on must be breaking, any or never")
|
|
219
|
+
with Store(_need_db(db)) as store:
|
|
220
|
+
domain = _site(store, domain)
|
|
221
|
+
versions = store.api_versions(domain)
|
|
222
|
+
if history:
|
|
223
|
+
_print_history(domain, versions)
|
|
224
|
+
return
|
|
225
|
+
try:
|
|
226
|
+
spec = site_spec(store, domain)
|
|
227
|
+
except NothingToGenerate as exc:
|
|
228
|
+
console.print(str(exc))
|
|
229
|
+
raise typer.Exit(code=exitcodes.FAILED) from exc
|
|
230
|
+
info = drift.version_for(store, domain, spec)
|
|
231
|
+
changes = list(info.changes)
|
|
232
|
+
if since is not None:
|
|
233
|
+
info, changes = _since(store, domain, since, info, changes)
|
|
234
|
+
if live:
|
|
235
|
+
changes += _live_changes(store, domain)
|
|
236
|
+
if live and not info.changes and changes: # only the live check found something
|
|
237
|
+
info = drift.VersionInfo(drift.bump(info.version, changes), changes, info.version,
|
|
238
|
+
True, info.history) # fmt: skip
|
|
239
|
+
if _options["json"]:
|
|
240
|
+
json_out(
|
|
241
|
+
{"site": domain, "version": info.version, "previous": info.previous,
|
|
242
|
+
"changes": drift.changes_json(changes),
|
|
243
|
+
"breaking": sum(c.severity == "breaking" for c in changes)}
|
|
244
|
+
) # fmt: skip
|
|
245
|
+
elif markdown:
|
|
246
|
+
sys.stdout.write(drift.to_markdown(domain, info, changes) + "\n")
|
|
247
|
+
elif not versions:
|
|
248
|
+
console.print(
|
|
249
|
+
f"No client version recorded for {domain} yet; the next `ghost-api generate` "
|
|
250
|
+
f"or run makes {info.version}."
|
|
251
|
+
)
|
|
252
|
+
elif not changes:
|
|
253
|
+
console.print(f"[green]No API changes[/] for {domain} since version {info.version}.")
|
|
254
|
+
else:
|
|
255
|
+
console.print(f"[bold]{domain}[/]: client {info.previous} -> {info.version}")
|
|
256
|
+
for change in changes:
|
|
257
|
+
style = {"breaking": "red", "additive": "green", "info": "dim"}[change.severity]
|
|
258
|
+
console.print(f" [{style}]{change.line()}[/]", highlight=False)
|
|
259
|
+
if not any(c.source == "live" for c in changes):
|
|
260
|
+
console.print(
|
|
261
|
+
"[dim]These are differences in the generated client: from the site's data, or "
|
|
262
|
+
"from a newer Ghost API typing things differently. --live checks the site "
|
|
263
|
+
"itself.[/]"
|
|
264
|
+
)
|
|
265
|
+
console.print("[dim]Run `ghost-api generate` to write the new client version.[/]")
|
|
266
|
+
breaking = any(c.severity == "breaking" for c in changes)
|
|
267
|
+
if (fail_on == "breaking" and breaking) or (fail_on == "any" and changes):
|
|
268
|
+
raise typer.Exit(code=exitcodes.FAILED)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _since(
|
|
272
|
+
store: Store, domain: str, since: str, info: Any, pending: list[Any]
|
|
273
|
+
) -> tuple[Any, list[Any]]:
|
|
274
|
+
"""Every change recorded after version `since`, plus any not yet recorded."""
|
|
275
|
+
from ghost_api import drift
|
|
276
|
+
|
|
277
|
+
versions = store.api_versions(domain)
|
|
278
|
+
known = [v["version"] for v in versions]
|
|
279
|
+
if since not in known:
|
|
280
|
+
console.print(f"No version {since} for {domain}. Versions: {', '.join(known) or 'none'}")
|
|
281
|
+
raise typer.Exit(code=exitcodes.USAGE)
|
|
282
|
+
newer = versions[: known.index(since)]
|
|
283
|
+
changes = [drift.Change(**c) for v in reversed(newer) for c in v["changes"]] + pending
|
|
284
|
+
latest = info.version if pending else (newer[0]["version"] if newer else since)
|
|
285
|
+
return drift.VersionInfo(latest, changes, since, bool(changes), versions), changes
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _print_history(domain: str, versions: list[dict[str, Any]]) -> None:
|
|
289
|
+
if _options["json"]:
|
|
290
|
+
json_out({"site": domain, "versions": [
|
|
291
|
+
{k: v for k, v in entry.items() if k != "snapshot"} for entry in versions
|
|
292
|
+
]}) # fmt: skip
|
|
293
|
+
return
|
|
294
|
+
if not versions:
|
|
295
|
+
console.print(f"No client versions recorded for {domain} yet.")
|
|
296
|
+
return
|
|
297
|
+
table = Table(title=f"Client versions for {domain}", title_justify="left")
|
|
298
|
+
for col in ("Version", "Date", "Changes"):
|
|
299
|
+
table.add_column(col, overflow="fold")
|
|
300
|
+
for entry in versions:
|
|
301
|
+
counts = [c["severity"] for c in entry["changes"]]
|
|
302
|
+
summary = ", ".join(f"{counts.count(s)} {s}" for s in ("breaking", "additive", "info")
|
|
303
|
+
if counts.count(s)) or "first version" # fmt: skip
|
|
304
|
+
table.add_row(entry["version"], str(entry["created_at"])[:10], summary)
|
|
305
|
+
console.print(table)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _live_changes(store: Store, domain: str) -> list[Any]:
|
|
309
|
+
from ghost_api import drift
|
|
310
|
+
|
|
311
|
+
return drift.live_check(
|
|
312
|
+
store, domain, on_progress=lambda text: console.print(f"[dim] {text}[/]")
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@app.command()
|
|
317
|
+
def mock(
|
|
318
|
+
domain: Annotated[str, typer.Argument(help=SITE_HELP)],
|
|
319
|
+
port: Annotated[int, typer.Option(help="Port on this computer.")] = 8901,
|
|
320
|
+
db: Annotated[Path | None, typer.Option(help="SQLite database path.")] = None,
|
|
321
|
+
) -> None:
|
|
322
|
+
"""Serve a local copy of a site's API from captured replies: work offline, test
|
|
323
|
+
front ends, never touch the real site."""
|
|
324
|
+
import uvicorn
|
|
325
|
+
|
|
326
|
+
from ghost_api.serve.mock import build_routes, create_mock_app
|
|
327
|
+
|
|
328
|
+
with Store(_need_db(db)) as store:
|
|
329
|
+
domain = _site(store, domain)
|
|
330
|
+
routes = build_routes(store, domain)
|
|
331
|
+
if not routes:
|
|
332
|
+
console.print(f"No captured replies for {domain}. Run `ghost-api run` first.")
|
|
333
|
+
raise typer.Exit(code=exitcodes.FAILED)
|
|
334
|
+
base = f"http://127.0.0.1:{port}"
|
|
335
|
+
if _options["json"]:
|
|
336
|
+
json_out(
|
|
337
|
+
{"site": domain, "base_url": base,
|
|
338
|
+
"routes": [f"{r.endpoint.method} {r.endpoint.path_template}" for r in routes]}
|
|
339
|
+
) # fmt: skip
|
|
340
|
+
console.print(f"[bold {ACCENT}]◆ Mock of {domain}[/] {base} (captured replies, no traffic)")
|
|
341
|
+
for r in routes:
|
|
342
|
+
console.print(
|
|
343
|
+
f" {r.endpoint.method:<6} {base}{r.endpoint.path_template}"
|
|
344
|
+
f" [dim]{len(r.samples)} repl{'y' if len(r.samples) == 1 else 'ies'}[/]"
|
|
345
|
+
)
|
|
346
|
+
console.print("[dim]Point your client's base URL here. Local only. Ctrl+C to stop.[/]")
|
|
347
|
+
hosts = {f"127.0.0.1:{port}", f"localhost:{port}"}
|
|
348
|
+
uvicorn.run(create_mock_app(routes, domain, hosts), host="127.0.0.1", port=port,
|
|
349
|
+
log_level="warning") # fmt: skip
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@app.command()
|
|
353
|
+
def adopt(
|
|
354
|
+
path: Annotated[Path, typer.Argument(help="The codebase to scan.")] = Path("."),
|
|
355
|
+
db: Annotated[Path | None, typer.Option(help="SQLite database path.")] = None,
|
|
356
|
+
) -> None:
|
|
357
|
+
"""Find hand-written calls to websites' hidden APIs in a codebase, and the typed client
|
|
358
|
+
method that replaces each one (for sites Ghost API has explored). Nothing is edited."""
|
|
359
|
+
from ghost_api.adopt import adopt as find_calls
|
|
360
|
+
from ghost_api.adopt import to_json
|
|
361
|
+
|
|
362
|
+
database = db or default_db_path()
|
|
363
|
+
store = Store(database) if database.exists() else None
|
|
364
|
+
try:
|
|
365
|
+
reports = find_calls(path.resolve(), store)
|
|
366
|
+
finally:
|
|
367
|
+
if store is not None:
|
|
368
|
+
store.close()
|
|
369
|
+
if _options["json"]:
|
|
370
|
+
json_out({"path": str(path.resolve()), "hosts": to_json(reports)})
|
|
371
|
+
return
|
|
372
|
+
if not reports:
|
|
373
|
+
console.print("No HTTP calls with a literal URL found.")
|
|
374
|
+
return
|
|
375
|
+
total = sum(len(r.calls) for r in reports)
|
|
376
|
+
console.print(f"[bold]{total} call(s) to {len(reports)} host(s)[/]")
|
|
377
|
+
for report in reports:
|
|
378
|
+
known = f"explored as {report.site}" if report.site else "not explored yet"
|
|
379
|
+
console.print(f"\n[bold {ACCENT}]{report.host}[/] [dim]{known}[/]")
|
|
380
|
+
table = Table(box=None, pad_edge=False, show_header=False)
|
|
381
|
+
for col in ("Where", "Call", "Replace with"):
|
|
382
|
+
table.add_column(col, overflow="fold")
|
|
383
|
+
for call in report.calls:
|
|
384
|
+
table.add_row(
|
|
385
|
+
f"[dim]{call.file}:{call.line}[/]",
|
|
386
|
+
f"{call.method} {call.url}",
|
|
387
|
+
f"[green]{call.replacement}[/]" if call.replacement else "[dim]-[/]",
|
|
388
|
+
)
|
|
389
|
+
console.print(table)
|
|
390
|
+
console.print(f" [dim]Next: {report.next_step}[/]")
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
@app.command("verify-spec")
|
|
394
|
+
def verify_spec(
|
|
395
|
+
spec_file: Annotated[Path, typer.Argument(help="A published OpenAPI file (YAML or JSON).")],
|
|
396
|
+
domain: Annotated[str, typer.Argument(help=SITE_HELP)],
|
|
397
|
+
db: Annotated[Path | None, typer.Option(help="SQLite database path.")] = None,
|
|
398
|
+
) -> None:
|
|
399
|
+
"""Compare a published OpenAPI spec with what the site really does: undocumented
|
|
400
|
+
endpoints, documented ones never seen, and reply fields that differ."""
|
|
401
|
+
from ghost_api.specdiff import SpecError, compare, load_spec, to_json
|
|
402
|
+
|
|
403
|
+
try:
|
|
404
|
+
spec = load_spec(spec_file)
|
|
405
|
+
except SpecError as exc:
|
|
406
|
+
console.print(f"[red]{exc}[/]")
|
|
407
|
+
raise typer.Exit(code=exitcodes.USAGE) from exc
|
|
408
|
+
with Store(_need_db(db)) as store:
|
|
409
|
+
domain = _site(store, domain)
|
|
410
|
+
checks = compare(spec, store.current_endpoints(domain))
|
|
411
|
+
if _options["json"]:
|
|
412
|
+
json_out({"site": domain, "spec": str(spec_file), "endpoints": to_json(checks)})
|
|
413
|
+
return
|
|
414
|
+
counts = {s: sum(c.status == s for c in checks) for s in
|
|
415
|
+
("documented", "undocumented", "not seen")} # fmt: skip
|
|
416
|
+
console.print(
|
|
417
|
+
f"[bold]{domain}[/] vs {spec_file.name}: {counts['documented']} documented and seen, "
|
|
418
|
+
f"[yellow]{counts['undocumented']} undocumented[/], "
|
|
419
|
+
f"[dim]{counts['not seen']} documented but not seen[/]"
|
|
420
|
+
)
|
|
421
|
+
for check in checks:
|
|
422
|
+
style = {"documented": "green", "undocumented": "yellow", "not seen": "dim"}[check.status]
|
|
423
|
+
console.print(f" [{style}]{check.status:<13}[/] {check.endpoint}", highlight=False)
|
|
424
|
+
if check.extra_fields:
|
|
425
|
+
console.print(f" [yellow]replies also have:[/] {', '.join(check.extra_fields)}")
|
|
426
|
+
if check.missing_fields:
|
|
427
|
+
console.print(
|
|
428
|
+
f" [dim]spec lists, never seen:[/] {', '.join(check.missing_fields)}"
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
@app.command()
|
|
433
|
+
def explain(
|
|
434
|
+
domain: Annotated[str, typer.Argument(help=SITE_HELP)],
|
|
435
|
+
db: Annotated[Path | None, typer.Option(help="SQLite database path.")] = None,
|
|
436
|
+
) -> None:
|
|
437
|
+
"""A plain-language summary of a site's API for someone new to it (no LLM)."""
|
|
438
|
+
from ghost_api.generator import site_spec
|
|
439
|
+
|
|
440
|
+
with Store(_need_db(db)) as store:
|
|
441
|
+
domain = _site(store, domain)
|
|
442
|
+
endpoints = store.current_endpoints(domain)
|
|
443
|
+
versions = store.api_versions(domain)
|
|
444
|
+
runs = store.list_runs(domain)
|
|
445
|
+
try:
|
|
446
|
+
spec = site_spec(store, domain)
|
|
447
|
+
names = {m.endpoint_key: m.name for m in spec.methods}
|
|
448
|
+
class_name = spec.class_name
|
|
449
|
+
except NothingToGenerate:
|
|
450
|
+
names, class_name = {}, None
|
|
451
|
+
if not endpoints:
|
|
452
|
+
console.print(f"Nothing is known about {domain} yet. Run `ghost-api run` first.")
|
|
453
|
+
raise typer.Exit(code=exitcodes.FAILED)
|
|
454
|
+
summaries = [endpoint_summary(e, domain) for e in endpoints]
|
|
455
|
+
login = {s["auth_type"] for s in summaries if s.get("auth_type") not in (None, "none")}
|
|
456
|
+
if _options["json"]:
|
|
457
|
+
json_out({"site": domain, "endpoints": summaries, "client": class_name,
|
|
458
|
+
"version": versions[0]["version"] if versions else None}) # fmt: skip
|
|
459
|
+
return
|
|
460
|
+
last = runs[0].started_at[:10] if runs else "?"
|
|
461
|
+
version = f", client {versions[0]['version']}" if versions else ""
|
|
462
|
+
console.print(f"[bold]{domain}[/] [dim]explored {last}{version}[/]\n")
|
|
463
|
+
console.print(
|
|
464
|
+
f"It has {len(endpoints)} API endpoint{'s' if len(endpoints) != 1 else ''}"
|
|
465
|
+
+ (
|
|
466
|
+
f"; some need a login or key ({', '.join(sorted(login))})."
|
|
467
|
+
if login
|
|
468
|
+
else "; none needs a login."
|
|
469
|
+
) # fmt: skip
|
|
470
|
+
)
|
|
471
|
+
for ep, s in zip(endpoints, summaries, strict=True):
|
|
472
|
+
fields = ", ".join(s.get("fields") or []) or "data"
|
|
473
|
+
paging = (s.get("pagination") or {}).get("style")
|
|
474
|
+
how = f", page by page ({paging})" if paging else ""
|
|
475
|
+
state = {"verified": "works", "failed": "failing", "unverified": "not tested"}.get(
|
|
476
|
+
str(s.get("status")), str(s.get("status") or "seen")
|
|
477
|
+
)
|
|
478
|
+
method = names.get(ep.key)
|
|
479
|
+
call = f" -> {class_name}.{method}()" if method and class_name else ""
|
|
480
|
+
console.print(
|
|
481
|
+
f" [bold]{s['method']} {s['path']}[/]: returns {fields}{how} [dim]({state})[/]{call}",
|
|
482
|
+
highlight=False,
|
|
483
|
+
)
|
|
484
|
+
console.print(
|
|
485
|
+
f"\n[dim]Data without code: ghost-api fetch {domain} · "
|
|
486
|
+
f"client: ghost-api generate {domain} · changes: ghost-api diff {domain} --live[/]"
|
|
487
|
+
)
|