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/manage.py
ADDED
|
@@ -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
|
+
)
|