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,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
+ )