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,437 @@
1
+ """Commands that manage Ghost API itself: init, doctor, inspect, forget."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ import time
8
+ from dataclasses import asdict
9
+ from pathlib import Path
10
+ from typing import Annotated, Any
11
+
12
+ import typer
13
+ from rich.table import Table
14
+
15
+ from ghost_api import exitcodes
16
+ from ghost_api.cli._common import (
17
+ SITE_HELP,
18
+ _check_lang,
19
+ _need_db,
20
+ _options,
21
+ _site,
22
+ app,
23
+ console,
24
+ json_out,
25
+ )
26
+ from ghost_api.config import (
27
+ default_db_path,
28
+ )
29
+ from ghost_api.export.site_data import endpoint_summary
30
+ from ghost_api.llm.doctor import check_providers
31
+ from ghost_api.llm.providers import default_providers
32
+ from ghost_api.memory.store import RunRow, Store
33
+ from ghost_api.project import find_project
34
+ from ghost_api.ui import keys
35
+ from ghost_api.ui.terminal import (
36
+ endpoint_table,
37
+ )
38
+
39
+
40
+ @app.command()
41
+ def forget(
42
+ domain: Annotated[str | None, typer.Argument(help=SITE_HELP)] = None,
43
+ llm_cache: Annotated[
44
+ bool, typer.Option("--llm-cache", help="Also clear cached LLM answers (all sites).")
45
+ ] = False,
46
+ keep_files: Annotated[
47
+ bool, typer.Option("--keep-files", help="Keep the output folder and screenshots.")
48
+ ] = False,
49
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Don't ask for confirmation.")] = False,
50
+ out: Annotated[Path, typer.Option(help="Output directory.")] = Path("output"),
51
+ db: Annotated[Path | None, typer.Option(help="SQLite database path.")] = None,
52
+ ) -> None:
53
+ """Delete what Ghost API stored about a site: runs, captured traffic, endpoints,
54
+ watches, screenshots and its output folder."""
55
+ import shutil
56
+
57
+ from ghost_api.pipeline import screenshots_dir
58
+
59
+ if domain is None and not llm_cache:
60
+ console.print("Name a site to forget, e.g. ghost-api forget quotes.toscrape.com")
61
+ raise typer.Exit(code=2)
62
+ path = _need_db(db)
63
+ with Store(path) as store:
64
+ site = _site(store, domain) if domain else None
65
+ what = f"everything stored for {site}" if site else ""
66
+ if llm_cache:
67
+ what += (" and " if what else "") + "the cached LLM answers"
68
+ if not yes:
69
+ from ghost_api.ui import keys
70
+
71
+ with keys.input_paused(): # normal typing inside the interactive session too
72
+ sure = typer.confirm(f"Delete {what}? This can't be undone.", default=False)
73
+ if not sure:
74
+ raise typer.Exit(code=1)
75
+ deleted: dict[str, Any] = {"site": site, "rows": {}, "folders": [], "llm_cache": 0}
76
+ if site:
77
+ from ghost_api.browser import auth_state
78
+
79
+ if auth_state.delete(path, site):
80
+ console.print(f"[dim]Deleted the saved login for {site}[/]")
81
+ from ghost_api.audit import audit_file
82
+
83
+ log = audit_file(path, site)
84
+ if log.exists():
85
+ log.unlink()
86
+ console.print(f"[dim]Deleted the audit log for {site}[/]")
87
+ counts, run_ids = store.forget_site(site)
88
+ rows = sum(counts.values())
89
+ deleted["rows"] = counts
90
+ console.print(f"Deleted {rows} stored row(s) for {site}.")
91
+ site_out = (out / site).resolve()
92
+ if not keep_files and rows and site_out.is_relative_to(out.resolve()):
93
+ folders = [screenshots_dir(path, r) for r in run_ids] + [site_out]
94
+ for folder in folders:
95
+ if folder.is_dir():
96
+ shutil.rmtree(folder, ignore_errors=True)
97
+ deleted["folders"].append(str(folder))
98
+ console.print(f"[dim]Removed {folder}[/]")
99
+ if llm_cache:
100
+ deleted["llm_cache"] = store.clear_llm_cache()
101
+ console.print(f"Cleared {deleted['llm_cache']} cached LLM answer(s).")
102
+ store.conn.execute("VACUUM") # really remove the data from the file
103
+ console.print("[dim]Copies you made elsewhere (clients, downloads) are not touched.[/]")
104
+ if _options["json"]:
105
+ json_out(deleted)
106
+
107
+
108
+ KEY_CHOICES = {
109
+ "1": ("Gemini (Google, free tier)", "GEMINI_API_KEY", "https://aistudio.google.com/apikey"),
110
+ "2": ("Groq (free tier)", "GROQ_API_KEY", "https://console.groq.com/keys"),
111
+ "3": ("OpenRouter", "OPENROUTER_API_KEY", "https://openrouter.ai/keys"),
112
+ }
113
+
114
+
115
+ @app.command()
116
+ def init(
117
+ lang: Annotated[str, typer.Option(help="Default client language: python or typescript.")] = (
118
+ "python"
119
+ ),
120
+ yes: Annotated[
121
+ bool, typer.Option("--yes", "-y", help="Don't ask: install the browser, skip the key.")
122
+ ] = False,
123
+ skip_browser: Annotated[
124
+ bool, typer.Option("--skip-browser", help="Don't download Chromium.")
125
+ ] = False,
126
+ ) -> None:
127
+ """Set up Ghost API here: project file, browser, LLM key, .gitignore. Safe to re-run."""
128
+ from ghost_api.browser.install import chromium_path, install_chromium
129
+ from ghost_api.project import PROJECT_FILE, ensure_gitignore, write_project
130
+
131
+ lang = _check_lang(lang)
132
+ root = Path.cwd()
133
+ done: dict[str, Any] = {"project": str(root / PROJECT_FILE)}
134
+
135
+ def ask(question: str, default: bool = True) -> bool:
136
+ if yes:
137
+ return default
138
+ with keys.input_paused():
139
+ return typer.confirm(question, default=default)
140
+
141
+ # 1. Project file
142
+ if (root / PROJECT_FILE).exists():
143
+ console.print(f"[dim]✓ {PROJECT_FILE} already here[/]")
144
+ done["project_created"] = False
145
+ else:
146
+ write_project(root, lang)
147
+ console.print(f"[green]✓[/] Created {PROJECT_FILE} (settings) and .ghost-api/ (data)")
148
+ done["project_created"] = True
149
+ if any((folder / ".git").exists() for folder in (root, *root.parents)):
150
+ added = ensure_gitignore(root)
151
+ done["gitignore_added"] = added
152
+ if added:
153
+ console.print(f"[green]✓[/] .gitignore now keeps out: {', '.join(added)}")
154
+
155
+ # 2. Browser
156
+ browser = chromium_path()
157
+ question = "Chromium (the browser Ghost API drives) is missing. Download it now (~150 MB)?"
158
+ if browser is None and not skip_browser and ask(question):
159
+ console.print("[dim]Downloading Chromium...[/]")
160
+ browser = chromium_path() if install_chromium() else None
161
+ done["chromium"] = str(browser) if browser else None
162
+ if browser:
163
+ console.print("[green]✓[/] Chromium is installed")
164
+ else:
165
+ console.print(
166
+ "[yellow]![/] No Chromium yet. Later: ghost-api init (or playwright install chromium)"
167
+ )
168
+
169
+ # 3. LLM key (optional: --no-agent runs work without one)
170
+ has_key = any(p.api_key for p in default_providers())
171
+ done["llm_key"] = has_key
172
+ if has_key:
173
+ console.print("[green]✓[/] An LLM key is set")
174
+ elif not yes and ask("Add an LLM key now? (Without one, runs only load and scroll pages.)"):
175
+ for number, (name, _env, url) in KEY_CHOICES.items():
176
+ console.print(f" {number}. {name} [dim]{url}[/]")
177
+ with keys.input_paused():
178
+ choice = typer.prompt("Provider", default="1")
179
+ name, env, _url = KEY_CHOICES.get(choice, KEY_CHOICES["1"])
180
+ key = typer.prompt(f"Paste your {env} (hidden)", hide_input=True).strip()
181
+ if key:
182
+ env_file = root / ".env"
183
+ lines = env_file.read_text(encoding="utf-8").splitlines() if env_file.exists() else []
184
+ lines = [line for line in lines if not line.startswith(f"{env}=")] + [f"{env}={key}"]
185
+ env_file.write_text("\n".join(lines) + "\n", encoding="utf-8")
186
+ os.environ[env] = key
187
+ done["llm_key"] = True
188
+ console.print(f"[green]✓[/] Saved {env} in .env (keep this file private)")
189
+ else:
190
+ console.print("[dim]No LLM key: add one later with ghost-api init[/]")
191
+
192
+ if _options["json"]:
193
+ json_out(done)
194
+ return
195
+ console.print(
196
+ "\n[bold]Ready.[/] Try:\n"
197
+ ' ghost-api run https://quotes.toscrape.com/scroll --goal "list quotes"\n'
198
+ " ghost-api (interactive session)\n"
199
+ " ghost-api doctor (check everything)"
200
+ )
201
+
202
+
203
+ @app.command()
204
+ def integrate(
205
+ target: Annotated[
206
+ str, typer.Argument(help="claude-code, cursor, vscode or desktop.")
207
+ ] = "claude-code",
208
+ path: Annotated[Path, typer.Option(help="The project folder.")] = Path("."),
209
+ dry_run: Annotated[
210
+ bool, typer.Option("--dry-run", help="Show what would be written, write nothing.")
211
+ ] = False,
212
+ ) -> None:
213
+ """Add Ghost API to an AI assistant for this project: the MCP server (explore sites,
214
+ fetch data, check changes), and for Claude Code a skill and /ghost-* commands."""
215
+ from ghost_api.integrations.setup import IntegrationError, desktop_snippet, plan, write
216
+
217
+ if target == "desktop":
218
+ console.print(
219
+ "Add this to Claude Desktop's config (Settings > Developer > Edit Config), "
220
+ "then restart Claude Desktop:"
221
+ )
222
+ console.print(desktop_snippet(), markup=False, highlight=False)
223
+ return
224
+ try:
225
+ files = plan(target, path.resolve())
226
+ except IntegrationError as exc:
227
+ console.print(f"[red]{exc}[/]")
228
+ raise typer.Exit(code=exitcodes.USAGE) from exc
229
+ if _options["json"]:
230
+ json_out({"target": target, "dry_run": dry_run, "files": [str(f) for f in files]})
231
+ if dry_run:
232
+ for file in files:
233
+ console.print(f"would write {file}")
234
+ return
235
+ for file in write(files):
236
+ console.print(f"[green]✓[/] {file}")
237
+ if target == "claude-code":
238
+ console.print(
239
+ "\nRestart Claude Code in this folder (approve the ghost-api server when asked), "
240
+ "then try:\n /ghost-explore https://quotes.toscrape.com/scroll quotes with authors"
241
+ )
242
+
243
+
244
+ @app.command()
245
+ def audit(
246
+ domain: Annotated[str, typer.Argument(help=SITE_HELP)],
247
+ limit: Annotated[int, typer.Option(help="Show the last N entries.")] = 50,
248
+ db: Annotated[Path | None, typer.Option(help="SQLite database path.")] = None,
249
+ ) -> None:
250
+ """Every request Ghost API sent to a site itself, and every request its rules blocked
251
+ (method, host, path, status; never query values, bodies or cookies)."""
252
+ from ghost_api.audit import audit_file, read
253
+
254
+ path = _need_db(db)
255
+ with Store(path) as store:
256
+ domain = _site(store, domain)
257
+ entries = read(path, domain, limit)
258
+ if _options["json"]:
259
+ json_out({"site": domain, "file": str(audit_file(path, domain)), "entries": entries})
260
+ return
261
+ if not entries:
262
+ console.print(f"Nothing audited for {domain} yet.")
263
+ return
264
+ table = Table(title=f"Audit: {domain} (last {len(entries)})", title_justify="left")
265
+ for col in ("When", "Run", "Request", "Status", "Decision"):
266
+ table.add_column(col, overflow="fold")
267
+ for e in entries:
268
+ request = f"{e.get('method', '')} {e.get('host', '')}{e.get('path', '')}".strip()
269
+ when = str(e["at"]).replace("T", " ")[:19]
270
+ status = str(e.get("status") or "-")
271
+ decision = str(e.get("decision", ""))
272
+ table.add_row(when, str(e.get("run") or "-"), request or "-", status, decision)
273
+ console.print(table)
274
+ console.print(f"[dim]Full log: {audit_file(path, domain)}[/]")
275
+
276
+
277
+ @app.command()
278
+ def doctor() -> None:
279
+ """Check your setup: Python, the browser, the project, the database and each LLM key
280
+ (one tiny call per provider)."""
281
+ import platform
282
+
283
+ from ghost_api.browser.install import chromium_path
284
+
285
+ started = time.monotonic()
286
+ project = find_project()
287
+ browser = chromium_path()
288
+ db = default_db_path()
289
+ setup = [
290
+ ("Python", platform.python_version(), sys.version_info >= (3, 11), "needs 3.11 or newer"),
291
+ ("Chromium", str(browser or "not installed"), browser is not None,
292
+ "run: ghost-api init (or: playwright install chromium)"),
293
+ ("Project", str(project.file if project else "none (using this folder)"), True, ""),
294
+ ("Database", f"{db} ({'exists' if db.exists() else 'created on the first run'})", True,
295
+ ""),
296
+ ] # fmt: skip
297
+ rows = check_providers(default_providers())
298
+ working = sum(r["ok"] for r in rows)
299
+ elapsed = time.monotonic() - started
300
+ if _options["json"]:
301
+ json_out(
302
+ {"setup": [{"check": n, "value": v, "ok": ok, "fix": fix} for n, v, ok, fix in setup],
303
+ "providers": rows, "working_providers": working, "seconds": round(elapsed, 1)}
304
+ ) # fmt: skip
305
+ else:
306
+ checks = Table(title="Setup", title_justify="left")
307
+ for col in ("Check", "Found", ""):
308
+ checks.add_column(col, overflow="fold")
309
+ for name, value, ok, fix in setup:
310
+ checks.add_row(name, value, "[green]OK[/]" if ok else f"[red]missing:[/] {fix}")
311
+ console.print(checks)
312
+ table = Table(title="LLM providers", title_justify="left")
313
+ for col in ("Provider", "Model", "Key", "Result", "Time"):
314
+ table.add_column(col)
315
+ for r in rows:
316
+ if not r["key"]:
317
+ table.add_row(r["provider"], r["model"], "[dim]missing[/]", "skipped", "-")
318
+ continue
319
+ result = "[green]OK[/]" if r["ok"] else f"[red]failed:[/] {r['detail'][:90]}"
320
+ table.add_row(r["provider"], r["model"], "set", result, f"{r['seconds']:.1f}s")
321
+ console.print(table)
322
+ if working:
323
+ console.print(
324
+ f"{working} provider(s) working in {elapsed:.1f}s. "
325
+ "Fallback order: GHOST_API_PROVIDERS in .env."
326
+ )
327
+ else:
328
+ console.print(
329
+ "[yellow]No LLM provider is working.[/] Runs still work with --no-agent "
330
+ "(the page is loaded and scrolled, no AI).\n"
331
+ "[bold]To add a key:[/] run ghost-api init, or put GEMINI_API_KEY=your-key "
332
+ "(or GROQ_API_KEY / OPENROUTER_API_KEY) in a .env file in this folder.\n"
333
+ "Free Gemini key: https://aistudio.google.com/apikey"
334
+ )
335
+ if not all(ok for _, _, ok, _ in setup) or not working:
336
+ raise typer.Exit(code=exitcodes.FAILED)
337
+
338
+
339
+ @app.command()
340
+ def inspect(
341
+ domain: Annotated[
342
+ str | None, typer.Argument(help="Site to show (or its domain); omit for all.")
343
+ ] = None,
344
+ run_id: Annotated[
345
+ int | None, typer.Option("--run", help="Replay one run's step log and test results.")
346
+ ] = None,
347
+ db: Annotated[Path | None, typer.Option(help="SQLite database path.")] = None,
348
+ ) -> None:
349
+ """Show what's stored for a site, or replay one run with --run."""
350
+ path = _need_db(db)
351
+ if run_id is not None:
352
+ _replay_run(path, run_id)
353
+ return
354
+ with Store(path) as store:
355
+ domain = _site(store, domain) if domain else None
356
+ runs = store.list_runs(domain)
357
+ endpoints = store.current_endpoints(domain) if domain else []
358
+ total_endpoints = len(endpoints) if domain else _endpoint_total(store, runs)
359
+ lessons = store.lessons_for(domain, limit=20) if domain else []
360
+ if _options["json"]:
361
+ json_out(
362
+ {"site": domain, "sites": sorted({r.domain for r in runs}),
363
+ "endpoint_count": total_endpoints, "runs": [asdict(r) for r in runs],
364
+ "endpoints": [endpoint_summary(e, domain) for e in endpoints] if domain else [],
365
+ "lessons": lessons}
366
+ ) # fmt: skip
367
+ return
368
+ if not runs:
369
+ console.print("No runs found.")
370
+ return
371
+ # Summary line above the table
372
+ sites_set = {r.domain for r in runs}
373
+ console.print(
374
+ f"[bold]{len(sites_set)} site(s) explored, "
375
+ f"{total_endpoints} endpoint(s) across {len(runs)} run(s).[/]"
376
+ )
377
+ table = Table(title="Runs")
378
+ for col in ("ID", "Domain", "Goal", "Status", "Requests", "LLM calls", "Started"):
379
+ table.add_column(col)
380
+ for r in runs:
381
+ table.add_row(
382
+ str(r.id),
383
+ r.domain,
384
+ r.goal,
385
+ r.status,
386
+ str(r.request_count),
387
+ str(r.llm_calls),
388
+ r.started_at,
389
+ )
390
+ console.print(table)
391
+ if domain:
392
+ if endpoints:
393
+ summaries = [endpoint_summary(e, domain) for e in endpoints]
394
+ console.print(endpoint_table(summaries, f"Endpoints ({len(summaries)})"))
395
+ else:
396
+ console.print("No endpoints stored for this domain.")
397
+ if lessons:
398
+ console.print("[bold]Lessons for next time[/] (newest first):")
399
+ for lesson in lessons:
400
+ console.print(f" - {lesson}")
401
+
402
+
403
+ def _endpoint_total(store: Store, runs: list[RunRow]) -> int:
404
+ total = 0
405
+ for site in {r.domain for r in runs}:
406
+ total += len(store.current_endpoints(site))
407
+ return total
408
+
409
+
410
+ RESULT_STYLE = {"confirmed": "green", "rejected": "red", "inconclusive": "yellow"}
411
+
412
+
413
+ def _replay_run(path: Path, run_id: int) -> None:
414
+ """Print a past run step by step, for debugging (NFR-33)."""
415
+ with Store(path) as store:
416
+ run_row = store.get_run(run_id)
417
+ if run_row is None:
418
+ console.print(f"Run #{run_id} not found.")
419
+ raise typer.Exit(code=1)
420
+ steps = store.steps_for_run(run_id)
421
+ hypotheses = store.hypotheses_for_run(run_id)
422
+ console.print(f"[bold]Run #{run_row.id}[/] on {run_row.domain}: {run_row.goal}")
423
+ console.print(
424
+ f"Status {run_row.status}, {run_row.request_count} requests, {run_row.llm_calls} LLM "
425
+ f"calls, started {run_row.started_at}"
426
+ )
427
+ for s in steps:
428
+ step_args = s["args"] if isinstance(s["args"], dict) else {}
429
+ args = ", ".join(f"{k}={v!r}" for k, v in step_args.items())
430
+ console.print(f"[bold]Step {s['step_no']}[/] {s['action']}({args}) [dim]{s['thought']}[/]")
431
+ console.print(f" [dim]{s['result']}[/]")
432
+ if not steps:
433
+ console.print("[dim]No agent steps in this run.[/]")
434
+ for h in hypotheses:
435
+ style = RESULT_STYLE[str(h["result"])]
436
+ label = f"[{style}]{h['result']:<12}[/] {h['endpoint_key']}: {h['text']}"
437
+ console.print(f" {label} [dim]{h['evidence']}[/]")
ghost_api/config.py ADDED
@@ -0,0 +1,132 @@
1
+ """Settings and default budgets, loaded from the environment and CLI options."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from dotenv import dotenv_values, load_dotenv
10
+
11
+ from ghost_api import __version__
12
+ from ghost_api.project import Project, ProjectError, find_project
13
+
14
+ TOOL_NAME = "GhostAPI"
15
+ # Appended to the browser's real user-agent so sites can identify the tool (NFR-20).
16
+ PROJECT_URL = "https://github.com/Danielraj5605/Ghost-api"
17
+ USER_AGENT_SUFFIX = f"{TOOL_NAME}/{__version__} (API discovery tool; +{PROJECT_URL})"
18
+
19
+ SUPPORTED_LANGUAGES = ("python", "typescript")
20
+
21
+
22
+ def check_language(lang: str) -> str:
23
+ """The one place the client language is validated (CLI options and Settings use it)."""
24
+ if lang not in SUPPORTED_LANGUAGES:
25
+ raise ValueError(
26
+ f"Unsupported language '{lang}'. Supported: {', '.join(SUPPORTED_LANGUAGES)}"
27
+ )
28
+ return lang
29
+
30
+
31
+ def tool_user_agent() -> str:
32
+ """The user-agent for requests Ghost API (not an LLM provider) sends to a target site.
33
+
34
+ Some sites (Wikimedia's, for one) refuse clients that don't say who runs them. If
35
+ GHOST_API_CONTACT is set (an email or a URL, chosen by the user), it is added. It is
36
+ never filled in automatically.
37
+ """
38
+ contact = os.getenv("GHOST_API_CONTACT", "").strip()
39
+ return f"{USER_AGENT_SUFFIX} (contact: {contact})" if contact else USER_AGENT_SUFFIX
40
+
41
+
42
+ @dataclass
43
+ class Settings:
44
+ """Everything a run needs. CLI options override the defaults below."""
45
+
46
+ url: str
47
+ goal: str
48
+ lang: str = "python"
49
+ headed: bool = False
50
+ max_steps: int = 40
51
+ max_llm_calls: int = 50
52
+ max_minutes: float = 15.0
53
+ rate_limit: float = 1.0 # max requests per second the tool itself sends
54
+ ignore_robots: bool = False
55
+ db_path: Path = Path("ghost_api.db")
56
+ output_dir: Path = Path("output")
57
+
58
+ def __post_init__(self) -> None:
59
+ check_language(self.lang)
60
+ if self.rate_limit <= 0:
61
+ raise ValueError("--rate-limit must be greater than 0")
62
+ for name in ("max_steps", "max_llm_calls"):
63
+ if getattr(self, name) < 1:
64
+ raise ValueError(f"--{name.replace('_', '-')} must be at least 1")
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class ApiKeys:
69
+ gemini: str | None
70
+ groq: str | None
71
+ openrouter: str | None
72
+
73
+
74
+ LOCAL_ONLY = {"GHOST_API_DB"}
75
+
76
+
77
+ def load_env() -> None:
78
+ """Load `.env` files without overriding real environment variables. First found wins
79
+ for each key: the current folder, the project folder (ghost-api.toml), your user
80
+ folder (~/.ghost-api/.env, for installed copies), then the Ghost API source folder
81
+ (a git checkout)."""
82
+ if os.getenv("GHOST_API_NO_DOTENV"): # tests: never pick up the developer's real keys
83
+ return
84
+ local = [Path.cwd() / ".env"]
85
+ project = _project()
86
+ if project is not None:
87
+ local.append(project.root / ".env")
88
+ shared = [state_dir() / ".env", Path(__file__).resolve().parent.parent / ".env"]
89
+ seen: set[Path] = set()
90
+ for path in [p.resolve() for p in local]:
91
+ if path.is_file() and path not in seen:
92
+ seen.add(path)
93
+ load_dotenv(path, override=False)
94
+ for path in [p.resolve() for p in shared]:
95
+ if path.is_file() and path not in seen:
96
+ seen.add(path)
97
+ # Keys and preferences only: a database named in another folder's .env must not
98
+ # replace this folder's or this project's database.
99
+ for name, value in dotenv_values(path).items():
100
+ if value is not None and name not in LOCAL_ONLY and name not in os.environ:
101
+ os.environ[name] = value
102
+
103
+
104
+ def state_dir() -> Path:
105
+ """Small files Ghost API keeps between runs (history, provider cooldowns)."""
106
+ return Path(os.getenv("GHOST_API_HOME") or Path.home() / ".ghost-api")
107
+
108
+
109
+ def default_db_path() -> Path:
110
+ """GHOST_API_DB, else the project's database (ghost-api.toml), else ./ghost_api.db."""
111
+ if os.getenv("GHOST_API_DB"):
112
+ return Path(os.environ["GHOST_API_DB"])
113
+ project = _project()
114
+ if project is not None:
115
+ project.database.parent.mkdir(parents=True, exist_ok=True)
116
+ return project.database
117
+ return Path("ghost_api.db")
118
+
119
+
120
+ def _project() -> Project | None:
121
+ try:
122
+ return find_project()
123
+ except ProjectError:
124
+ return None # reported by the CLI; settings fall back to the current folder
125
+
126
+
127
+ def api_keys() -> ApiKeys:
128
+ return ApiKeys(
129
+ gemini=os.getenv("GEMINI_API_KEY") or None,
130
+ groq=os.getenv("GROQ_API_KEY") or None,
131
+ openrouter=os.getenv("OPENROUTER_API_KEY") or None,
132
+ )