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/ui/terminal.py
ADDED
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
"""Terminal interface: renders pipeline events (`ghost_api/events.py`).
|
|
2
|
+
|
|
3
|
+
Two modes:
|
|
4
|
+
live For an interactive terminal, in the style of Claude Code: section headers
|
|
5
|
+
(`⏺ Exploring`), compact indented results (`⎿`), a spinner status line that
|
|
6
|
+
updates in place (phase stepper, time, requests, endpoints, LLM usage), and a
|
|
7
|
+
summary card at the end.
|
|
8
|
+
plain Simple lines, for logs, CI, `--plain`, and when output isn't a terminal.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Iterator
|
|
16
|
+
from contextlib import contextmanager
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from rich.console import Console, Group, RenderableType
|
|
21
|
+
from rich.live import Live
|
|
22
|
+
from rich.panel import Panel
|
|
23
|
+
from rich.spinner import Spinner
|
|
24
|
+
from rich.table import Table
|
|
25
|
+
from rich.text import Text
|
|
26
|
+
|
|
27
|
+
from ghost_api.events import (
|
|
28
|
+
AgentStep,
|
|
29
|
+
ClientGenerated,
|
|
30
|
+
EndpointsUpdated,
|
|
31
|
+
Event,
|
|
32
|
+
HypothesisTested,
|
|
33
|
+
NetworkSummary,
|
|
34
|
+
Notice,
|
|
35
|
+
PhaseChanged,
|
|
36
|
+
ProbeFinished,
|
|
37
|
+
RequestCaptured,
|
|
38
|
+
RunFinished,
|
|
39
|
+
RunStarted,
|
|
40
|
+
UsageUpdated,
|
|
41
|
+
VerifyRound,
|
|
42
|
+
)
|
|
43
|
+
from ghost_api.ui import keys
|
|
44
|
+
|
|
45
|
+
ACCENT = "#4f7cff" # the brand blue, a shade lighter so it reads on dark terminals
|
|
46
|
+
|
|
47
|
+
# The favicon's ghost (docs/assets/favicon.svg) as 12x12 pixels: a dome, two eyes, a
|
|
48
|
+
# scalloped hem. Two pixel rows share one line of half-blocks, so it prints 12 x 6 and
|
|
49
|
+
# looks square in a terminal. The eyes are holes, so they take the terminal's background.
|
|
50
|
+
GHOST_PIXELS = (
|
|
51
|
+
"....####....",
|
|
52
|
+
"..########..",
|
|
53
|
+
".##########.",
|
|
54
|
+
"############",
|
|
55
|
+
"###..##..###",
|
|
56
|
+
"###..##..###",
|
|
57
|
+
"############",
|
|
58
|
+
"############",
|
|
59
|
+
"############",
|
|
60
|
+
"############",
|
|
61
|
+
"############",
|
|
62
|
+
"##.##..##.##",
|
|
63
|
+
)
|
|
64
|
+
_HALF_BLOCKS = {(False, False): " ", (True, False): "▀", (False, True): "▄", (True, True): "█"}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def ghost_avatar() -> Text:
|
|
68
|
+
"""Ghost API's avatar, drawn with half-block characters in the accent colour."""
|
|
69
|
+
lines = []
|
|
70
|
+
for top, bottom in zip(GHOST_PIXELS[::2], GHOST_PIXELS[1::2], strict=True):
|
|
71
|
+
lines.append(
|
|
72
|
+
"".join(_HALF_BLOCKS[(t == "#", b == "#")] for t, b in zip(top, bottom, strict=True))
|
|
73
|
+
)
|
|
74
|
+
return Text("\n".join(lines), style=ACCENT, no_wrap=True)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
METHOD_STYLE = {
|
|
78
|
+
"GET": "bold #22c55e",
|
|
79
|
+
"POST": "bold #3b82f6",
|
|
80
|
+
"PUT": "bold #f59e0b",
|
|
81
|
+
"PATCH": "bold #f59e0b",
|
|
82
|
+
"DELETE": "bold #ef4444",
|
|
83
|
+
}
|
|
84
|
+
STATUS_STYLE = {"verified": "green", "unverified": "yellow", "failed": "red"}
|
|
85
|
+
CONFIDENCE_STYLE = {"High": "green", "Medium": "yellow", "Low": "red"}
|
|
86
|
+
RESULT_MARK = {
|
|
87
|
+
"confirmed": ("✓", "green"),
|
|
88
|
+
"rejected": ("✗", "red"),
|
|
89
|
+
"inconclusive": ("?", "yellow"),
|
|
90
|
+
}
|
|
91
|
+
LEVEL_MARK = {
|
|
92
|
+
"info": ("⎿", "dim"),
|
|
93
|
+
"success": ("✓", "green"),
|
|
94
|
+
"warning": ("!", "yellow"),
|
|
95
|
+
"error": ("✗", "red"),
|
|
96
|
+
}
|
|
97
|
+
PHASE_TITLE = {
|
|
98
|
+
"checks": "Checking the site's rules",
|
|
99
|
+
"explore": "Exploring the site",
|
|
100
|
+
"test": "Testing endpoints with direct calls",
|
|
101
|
+
"generate": "Generating the client",
|
|
102
|
+
"verify": "Verifying against the live API",
|
|
103
|
+
"learn": "Saving what was learned",
|
|
104
|
+
"done": "Done",
|
|
105
|
+
}
|
|
106
|
+
STEPPER = ("explore", "test", "generate", "verify", "learn")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def method_text(method: str) -> Text:
|
|
110
|
+
return Text(f"{method:<6}", style=METHOD_STYLE.get(method, "bold"))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# Set by the interactive session (ui/repl.py), so hints name its /commands.
|
|
114
|
+
SESSION = {"active": False}
|
|
115
|
+
# How far the current run got, for the message shown when it is stopped at once.
|
|
116
|
+
PROGRESS: dict[str, Any] = {"site": "", "step": 0, "endpoints": 0}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def stop_message(site: str = "") -> str:
|
|
120
|
+
"""'Stopped on SITE after step 6. Found 3 endpoints so far; what was captured is saved.'"""
|
|
121
|
+
where = f" on {PROGRESS['site'] or site}" if (PROGRESS["site"] or site) else ""
|
|
122
|
+
step = f" after step {PROGRESS['step']}" if PROGRESS["step"] else ""
|
|
123
|
+
count = PROGRESS["endpoints"]
|
|
124
|
+
found = f"Found {count} endpoint{'s' if count != 1 else ''} so far; " if count else ""
|
|
125
|
+
return f"Stopped{where}{step}. {found}what was captured is saved."
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _next_steps() -> str:
|
|
129
|
+
if SESSION["active"]:
|
|
130
|
+
return "Next: /fetch to download the data · /watch add for alerts · /ui for the dashboard"
|
|
131
|
+
return "Next: ghost-api fetch SITE (download the data) · ghost-api ui (open the dashboard)"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def file_url(path: str | Path) -> str:
|
|
135
|
+
"""A local path as a file:/// URL: E:/workspace/.../report.html."""
|
|
136
|
+
return Path(path).resolve().as_uri()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _hyperlinks_supported() -> bool:
|
|
140
|
+
return bool(
|
|
141
|
+
os.environ.get("WT_SESSION")
|
|
142
|
+
or os.environ.get("TERM_PROGRAM") in {"vscode", "iTerm.app", "WezTerm"}
|
|
143
|
+
or os.environ.get("GHOST_API_SHORT_LINKS")
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def file_link(path: str | Path, label: str | None = None) -> Text:
|
|
148
|
+
"""A clickable link to a local file (Ctrl+click in Windows Terminal, VS Code...).
|
|
149
|
+
|
|
150
|
+
Terminals that understand hyperlinks show a short relative path; others (the classic
|
|
151
|
+
cmd window) show the full file:/// URL, which they can still open."""
|
|
152
|
+
if not str(path):
|
|
153
|
+
return Text("")
|
|
154
|
+
url = file_url(path)
|
|
155
|
+
if label is None and not _hyperlinks_supported():
|
|
156
|
+
label = url
|
|
157
|
+
if label is None:
|
|
158
|
+
try:
|
|
159
|
+
label = str(Path(path).resolve().relative_to(Path.cwd()))
|
|
160
|
+
except ValueError:
|
|
161
|
+
label = str(Path(path).name)
|
|
162
|
+
return Text(label, style=f"link {url} underline")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def endpoint_table(endpoints: list[dict[str, Any]], title: str | None = None) -> Table:
|
|
166
|
+
"""A compact endpoint table from `pipeline.endpoint_summary` dicts."""
|
|
167
|
+
table = Table(title=title, box=None, pad_edge=False, show_edge=False, header_style="dim")
|
|
168
|
+
for col in ("", "Endpoint", "Status", "Confidence", "Paging", "Auth", "Returns"):
|
|
169
|
+
table.add_column(col, overflow="fold")
|
|
170
|
+
for ep in endpoints:
|
|
171
|
+
status = ep.get("status") or "discovered"
|
|
172
|
+
paging = "-"
|
|
173
|
+
if ep.get("pagination"):
|
|
174
|
+
p = ep["pagination"]
|
|
175
|
+
paging = f"{p['style']}: {p['param']}" + ("" if p.get("confirmed") else "?")
|
|
176
|
+
path = ep["label"].split(" ", 1)[1] if " " in ep["label"] else ep["label"]
|
|
177
|
+
table.add_row(
|
|
178
|
+
method_text(ep["method"]),
|
|
179
|
+
Text(path, style="bold"),
|
|
180
|
+
Text(status, style=STATUS_STYLE.get(status, "dim")),
|
|
181
|
+
Text(
|
|
182
|
+
ep.get("confidence", "-"), style=CONFIDENCE_STYLE.get(ep.get("confidence", ""), "")
|
|
183
|
+
),
|
|
184
|
+
paging,
|
|
185
|
+
ep.get("auth_type") or "-",
|
|
186
|
+
Text(ep.get("fields", "-"), style="dim"),
|
|
187
|
+
)
|
|
188
|
+
return table
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class TerminalRenderer:
|
|
192
|
+
"""Subscribe `renderer.handle` to an EventBus; call `close()` when the run is over."""
|
|
193
|
+
|
|
194
|
+
def __init__(self, console: Console | None = None, plain: bool | None = None) -> None:
|
|
195
|
+
self.console = console or Console()
|
|
196
|
+
self.plain = (not self.console.is_terminal) if plain is None else plain
|
|
197
|
+
self.live: Live | None = None
|
|
198
|
+
self.started = time.monotonic()
|
|
199
|
+
self.phase = "checks"
|
|
200
|
+
self.domain = ""
|
|
201
|
+
self.requests = 0
|
|
202
|
+
self.endpoints: list[dict[str, Any]] = []
|
|
203
|
+
self.client: ClientGenerated | None = None
|
|
204
|
+
self.usage: UsageUpdated | None = None
|
|
205
|
+
self.activity = "Starting..."
|
|
206
|
+
self._probe_label = ""
|
|
207
|
+
|
|
208
|
+
# --- lifecycle ---
|
|
209
|
+
|
|
210
|
+
def start(self) -> None:
|
|
211
|
+
if not self.plain and self.live is None:
|
|
212
|
+
self.live = Live(
|
|
213
|
+
self._status(), console=self.console, refresh_per_second=10, transient=True
|
|
214
|
+
)
|
|
215
|
+
self.live.start()
|
|
216
|
+
|
|
217
|
+
def close(self) -> None:
|
|
218
|
+
if self.live is not None:
|
|
219
|
+
self.live.stop()
|
|
220
|
+
self.live = None
|
|
221
|
+
|
|
222
|
+
@contextmanager
|
|
223
|
+
def paused(self) -> Iterator[None]:
|
|
224
|
+
"""Stop the status line while asking the person something."""
|
|
225
|
+
was_live = self.live is not None
|
|
226
|
+
self.close()
|
|
227
|
+
try:
|
|
228
|
+
yield
|
|
229
|
+
finally:
|
|
230
|
+
if was_live:
|
|
231
|
+
self.start()
|
|
232
|
+
|
|
233
|
+
def ask(self, question: str) -> str:
|
|
234
|
+
with self.paused(), keys.input_paused():
|
|
235
|
+
self.console.print(f"\n[bold yellow]The agent asks:[/] {question}")
|
|
236
|
+
return self.console.input("[bold]Your answer[/] (press Enter when done): ")
|
|
237
|
+
|
|
238
|
+
# --- events ---
|
|
239
|
+
|
|
240
|
+
def handle(self, event: Event) -> None:
|
|
241
|
+
method = getattr(self, f"_on_{event.kind}", None)
|
|
242
|
+
if method is not None:
|
|
243
|
+
method(event)
|
|
244
|
+
if self.live is not None:
|
|
245
|
+
self.live.update(self._status())
|
|
246
|
+
|
|
247
|
+
def _print(self, *items: RenderableType | str) -> None:
|
|
248
|
+
target = self.live.console if self.live is not None else self.console
|
|
249
|
+
target.print(*items)
|
|
250
|
+
|
|
251
|
+
def _on_run_started(self, e: RunStarted) -> None:
|
|
252
|
+
self.domain = e.domain
|
|
253
|
+
self.started = time.monotonic()
|
|
254
|
+
PROGRESS.update(site=e.domain, step=0, endpoints=0)
|
|
255
|
+
if self.plain:
|
|
256
|
+
self._print(f"[bold]Goal:[/] {e.goal}")
|
|
257
|
+
self._print("[bold]Exploring site...[/]")
|
|
258
|
+
return
|
|
259
|
+
title = "Refreshing" if e.mode == "refresh" else "Exploring"
|
|
260
|
+
self._print(
|
|
261
|
+
Text.assemble(
|
|
262
|
+
("◆ Ghost API ", f"bold {ACCENT}"), (f"{title} ", "dim"), (e.domain, "bold")
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
self._print(Text.assemble((" goal ", "dim"), e.goal))
|
|
266
|
+
self._print("")
|
|
267
|
+
|
|
268
|
+
def _on_phase(self, e: PhaseChanged) -> None:
|
|
269
|
+
self.phase = e.phase
|
|
270
|
+
self.activity = PHASE_TITLE.get(e.phase, e.phase)
|
|
271
|
+
if not self.plain and e.phase not in ("checks", "done"):
|
|
272
|
+
self._print(Text.assemble(("⏺ ", ACCENT), (PHASE_TITLE[e.phase], "bold")))
|
|
273
|
+
|
|
274
|
+
def _on_notice(self, e: Notice) -> None:
|
|
275
|
+
if self.plain:
|
|
276
|
+
style = {"success": "green", "warning": "yellow", "error": "red"}.get(e.level)
|
|
277
|
+
prefix = {"success": "OK ", "warning": "! ", "error": ""}.get(e.level, "")
|
|
278
|
+
text = f"[{style}]{prefix}{e.text}[/]" if style else e.text
|
|
279
|
+
self._print(text)
|
|
280
|
+
else:
|
|
281
|
+
mark, style = LEVEL_MARK[e.level]
|
|
282
|
+
self._print(
|
|
283
|
+
Text.assemble(
|
|
284
|
+
(" ", ""), (f"{mark} ", style), (e.text, "" if e.level == "info" else style)
|
|
285
|
+
)
|
|
286
|
+
)
|
|
287
|
+
if e.detail:
|
|
288
|
+
self._print(Text(f" {e.detail}", style="dim"))
|
|
289
|
+
if e.level == "info":
|
|
290
|
+
self.activity = e.text
|
|
291
|
+
|
|
292
|
+
def _on_request(self, e: RequestCaptured) -> None:
|
|
293
|
+
self.requests += 1
|
|
294
|
+
if self.plain:
|
|
295
|
+
tag = "third-party " if e.third_party else ""
|
|
296
|
+
self._print(f" [dim {ACCENT}]{tag}{e.method} {e.status} {_short(e.url, 70)}[/]")
|
|
297
|
+
|
|
298
|
+
def _on_step(self, e: AgentStep) -> None:
|
|
299
|
+
PROGRESS["step"] = e.step
|
|
300
|
+
args = ", ".join(f"{k}={v!r}" for k, v in e.args.items() if v not in (None, "", False))
|
|
301
|
+
if self.plain:
|
|
302
|
+
self._print(f"[bold]Step {e.step}[/] {e.action}({args}) [dim]{e.thought}[/]")
|
|
303
|
+
for label in e.new_endpoints:
|
|
304
|
+
self._print(f" [green]+ new endpoint[/] {label}")
|
|
305
|
+
if e.result.startswith(("rejected", "failed")):
|
|
306
|
+
self._print(f" [yellow]{e.result}[/]")
|
|
307
|
+
return
|
|
308
|
+
self._print(
|
|
309
|
+
Text.assemble(
|
|
310
|
+
(" ⎿ ", "dim"),
|
|
311
|
+
(f"Step {e.step} ", "bold"),
|
|
312
|
+
(f"{e.action}", ACCENT),
|
|
313
|
+
(f"({args})", "dim"),
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
self._print(Text(f" {e.thought}", style="italic dim"))
|
|
317
|
+
for label in e.new_endpoints:
|
|
318
|
+
method, _, path = label.partition(" ")
|
|
319
|
+
self._print(
|
|
320
|
+
Text.assemble((" + ", "green"), method_text(method), (path, "bold green"))
|
|
321
|
+
)
|
|
322
|
+
if e.result.startswith(("rejected", "failed")):
|
|
323
|
+
self._print(Text(f" {e.result}", style="yellow"))
|
|
324
|
+
self.activity = f"Step {e.step}: {e.action}"
|
|
325
|
+
|
|
326
|
+
def _on_network(self, e: NetworkSummary) -> None:
|
|
327
|
+
parts = [f"{e.direct_calls} direct call(s)", e.robots]
|
|
328
|
+
if e.blocked:
|
|
329
|
+
parts.append(f"{len(e.blocked)} blocked")
|
|
330
|
+
if e.saved_login:
|
|
331
|
+
parts.append("saved login")
|
|
332
|
+
self._print(Text(" Network: " + " · ".join(parts), style="dim"))
|
|
333
|
+
|
|
334
|
+
def _on_endpoints(self, e: EndpointsUpdated) -> None:
|
|
335
|
+
self.endpoints = e.endpoints
|
|
336
|
+
PROGRESS["endpoints"] = len(e.endpoints)
|
|
337
|
+
|
|
338
|
+
def _on_hypothesis(self, e: HypothesisTested) -> None:
|
|
339
|
+
mark, style = RESULT_MARK[e.result]
|
|
340
|
+
if self.plain:
|
|
341
|
+
if e.endpoint != self._probe_label:
|
|
342
|
+
self._print(f" [bold]{e.endpoint}[/]")
|
|
343
|
+
self._probe_label = e.endpoint
|
|
344
|
+
self._print(f" [{style}]{e.result:<12}[/] {e.text} [dim]{e.evidence}[/]")
|
|
345
|
+
return
|
|
346
|
+
if e.endpoint != self._probe_label:
|
|
347
|
+
method, _, path = e.endpoint.partition(" ")
|
|
348
|
+
self._print(Text.assemble((" ⎿ ", "dim"), method_text(method), (path, "bold")))
|
|
349
|
+
self._probe_label = e.endpoint
|
|
350
|
+
self._print(Text.assemble((f" {mark} ", style), e.text, (f" {e.evidence}", "dim")))
|
|
351
|
+
|
|
352
|
+
def _on_probe_done(self, e: ProbeFinished) -> None:
|
|
353
|
+
text = f"{e.direct_calls} direct calls made"
|
|
354
|
+
self._print(f"[dim]{text}[/]" if self.plain else Text(f" ⎿ {text}", style="dim"))
|
|
355
|
+
|
|
356
|
+
def _on_verify_round(self, e: VerifyRound) -> None:
|
|
357
|
+
parts = [
|
|
358
|
+
(f"{e.passed} passed", "green"),
|
|
359
|
+
(f"{e.failed} failed", "red"),
|
|
360
|
+
(f"{e.skipped} skipped", "yellow"),
|
|
361
|
+
]
|
|
362
|
+
shown = [(t, s) for t, s in parts if not t.startswith("0 ")]
|
|
363
|
+
if self.plain:
|
|
364
|
+
summary = ", ".join(f"[{s}]{t}[/]" for t, s in shown) or "no tests"
|
|
365
|
+
self._print(f" [bold]Round {e.number}:[/] {summary}")
|
|
366
|
+
for f in e.failures:
|
|
367
|
+
self._print(f" [red]failed[/] {f['name']} [dim]{f['message'][:110]}[/]")
|
|
368
|
+
for r in e.repairs:
|
|
369
|
+
self._print(f" [{ACCENT}]repair[/] {r['method']} ({r['kind']}): {r['action']}")
|
|
370
|
+
return
|
|
371
|
+
line = Text.assemble((" ⎿ ", "dim"), (f"Round {e.number} ", "bold"))
|
|
372
|
+
for i, (t, s) in enumerate(shown):
|
|
373
|
+
line.append((" · " if i else "") + t, style=s)
|
|
374
|
+
self._print(line)
|
|
375
|
+
for f in e.failures:
|
|
376
|
+
self._print(
|
|
377
|
+
Text.assemble((" ✗ ", "red"), f["name"], (f" {f['message'][:100]}", "dim"))
|
|
378
|
+
)
|
|
379
|
+
for r in e.repairs:
|
|
380
|
+
self._print(
|
|
381
|
+
Text.assemble(
|
|
382
|
+
(" ↻ ", ACCENT),
|
|
383
|
+
(f"{r['method']} ", "bold"),
|
|
384
|
+
(f"({r['kind']}) ", "dim"),
|
|
385
|
+
r["action"],
|
|
386
|
+
)
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
def _on_client(self, e: ClientGenerated) -> None:
|
|
390
|
+
self.client = e
|
|
391
|
+
|
|
392
|
+
def _on_usage(self, e: UsageUpdated) -> None:
|
|
393
|
+
self.usage = e
|
|
394
|
+
|
|
395
|
+
def _on_run_finished(self, e: RunFinished) -> None:
|
|
396
|
+
self.close()
|
|
397
|
+
if self.plain:
|
|
398
|
+
self._plain_summary(e)
|
|
399
|
+
else:
|
|
400
|
+
self.console.print()
|
|
401
|
+
self.console.print(self._summary_card(e))
|
|
402
|
+
|
|
403
|
+
# --- the live status line ---
|
|
404
|
+
|
|
405
|
+
def _status(self) -> RenderableType:
|
|
406
|
+
elapsed = time.monotonic() - self.started
|
|
407
|
+
stepper = Text(" ")
|
|
408
|
+
current = STEPPER.index(self.phase) if self.phase in STEPPER else -1
|
|
409
|
+
if self.phase in ("done",):
|
|
410
|
+
current = len(STEPPER)
|
|
411
|
+
for i, phase in enumerate(STEPPER):
|
|
412
|
+
if i < current:
|
|
413
|
+
stepper.append(f"✓ {phase.title()} ", style="green")
|
|
414
|
+
elif i == current:
|
|
415
|
+
stepper.append(f"◐ {phase.title()} ", style=f"bold {ACCENT}")
|
|
416
|
+
else:
|
|
417
|
+
stepper.append(f"○ {phase.title()} ", style="dim")
|
|
418
|
+
stats = Text(
|
|
419
|
+
f"{elapsed:4.0f}s · {_count(self.requests, 'request')} · "
|
|
420
|
+
f"{_count(len(self.endpoints), 'endpoint')}",
|
|
421
|
+
style="dim",
|
|
422
|
+
)
|
|
423
|
+
if self.usage is not None:
|
|
424
|
+
stats.append(
|
|
425
|
+
f" · LLM {self.usage.answered} calls · {self.usage.tokens / 1000:.1f}k tok",
|
|
426
|
+
style="dim",
|
|
427
|
+
)
|
|
428
|
+
spinner = Spinner("dots", text=Text(self.activity[:90], style=ACCENT), style=ACCENT)
|
|
429
|
+
return Group(Text(""), spinner, stepper, Text.assemble((" ", ""), stats))
|
|
430
|
+
|
|
431
|
+
# --- the end of a run ---
|
|
432
|
+
|
|
433
|
+
def _summary_card(self, e: RunFinished) -> Panel:
|
|
434
|
+
ok = e.status in ("goal-covered", "finished", "captured", "refreshed")
|
|
435
|
+
title = Text.assemble(
|
|
436
|
+
("✓ " if ok else "! ", "green" if ok else "yellow"),
|
|
437
|
+
(f"{e.status}", "bold"),
|
|
438
|
+
(f" · {e.seconds:.0f}s", "dim"),
|
|
439
|
+
)
|
|
440
|
+
body: list[RenderableType] = [Text(e.reason, style="dim"), Text("")]
|
|
441
|
+
if self.endpoints:
|
|
442
|
+
body.append(endpoint_table(self.endpoints))
|
|
443
|
+
else:
|
|
444
|
+
body.append(
|
|
445
|
+
Text(
|
|
446
|
+
"No JSON API was found. The site may build its pages on the server.",
|
|
447
|
+
style="yellow",
|
|
448
|
+
)
|
|
449
|
+
)
|
|
450
|
+
if self.client is not None:
|
|
451
|
+
c = self.client
|
|
452
|
+
body += [
|
|
453
|
+
Text(""),
|
|
454
|
+
Text.assemble(
|
|
455
|
+
("Client ", "dim"), (c.class_name, "bold"), (f" ({c.lang})", "dim")
|
|
456
|
+
),
|
|
457
|
+
]
|
|
458
|
+
for m in c.methods:
|
|
459
|
+
sig = f"{m['name']}({', '.join(m['args'])}) -> {m['returns']}"
|
|
460
|
+
extra = f" + {m['iterator']}()" if m.get("iterator") else ""
|
|
461
|
+
body.append(Text.assemble((" ", ""), (sig, ACCENT), (extra, "dim")))
|
|
462
|
+
for cred in c.credentials:
|
|
463
|
+
body.append(Text(f" needs {cred['arg']}: set {cred['env']}", style="yellow"))
|
|
464
|
+
body += [Text(""), Text("Files ", style="dim") + file_link(c.directory)]
|
|
465
|
+
body.append(Text("Use it ", style="dim") + Text(_use_it(c), style="bold"))
|
|
466
|
+
elif e.generation_error:
|
|
467
|
+
body += [Text(""), Text(f"No client generated: {e.generation_error}", style="yellow")]
|
|
468
|
+
if e.exposure:
|
|
469
|
+
body += [
|
|
470
|
+
Text(""),
|
|
471
|
+
Text(f"⚠ {e.exposure}", style="bold yellow"),
|
|
472
|
+
Text(
|
|
473
|
+
' Ghost API hid these values. See the report\'s "Personal data" section.',
|
|
474
|
+
style="dim",
|
|
475
|
+
), # fmt: skip
|
|
476
|
+
]
|
|
477
|
+
body.append(Text("Report ", style="dim") + file_link(e.report_html or e.report))
|
|
478
|
+
if e.report_html:
|
|
479
|
+
body.append(
|
|
480
|
+
Text(" Ctrl+click to open it in your browser, or /open", style="dim")
|
|
481
|
+
)
|
|
482
|
+
if e.lessons_learned:
|
|
483
|
+
body.append(
|
|
484
|
+
Text(f"Remembered {e.lessons_learned} lesson(s) for next time", style="dim")
|
|
485
|
+
)
|
|
486
|
+
if self.usage is not None:
|
|
487
|
+
u = self.usage
|
|
488
|
+
body.append(
|
|
489
|
+
Text(
|
|
490
|
+
f"LLM {u.answered} calls · {u.tokens:,} tokens · {u.cache_hits} cached",
|
|
491
|
+
style="dim",
|
|
492
|
+
)
|
|
493
|
+
)
|
|
494
|
+
body += [
|
|
495
|
+
Text(""),
|
|
496
|
+
Text(_next_steps(), style=f"dim {ACCENT}"),
|
|
497
|
+
]
|
|
498
|
+
return Panel(
|
|
499
|
+
Group(*body), title=title, title_align="left", border_style=ACCENT, padding=(1, 2)
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
def _plain_summary(self, e: RunFinished) -> None:
|
|
503
|
+
self.console.print()
|
|
504
|
+
self.console.print(f"[bold]Finished:[/] {e.status} ({e.reason})")
|
|
505
|
+
if self.endpoints:
|
|
506
|
+
self.console.print(endpoint_table(self.endpoints, f"Endpoints ({len(self.endpoints)})"))
|
|
507
|
+
else:
|
|
508
|
+
self.console.print(
|
|
509
|
+
"[yellow]No JSON API requests captured.[/] The site may render on the server."
|
|
510
|
+
)
|
|
511
|
+
if e.filtered:
|
|
512
|
+
noise = ", ".join(
|
|
513
|
+
f"{k}: {v}" for k, v in sorted(e.filtered.items(), key=lambda x: -x[1])
|
|
514
|
+
)
|
|
515
|
+
self.console.print(f"[dim]Filtered out: {noise}[/]")
|
|
516
|
+
self.console.print(
|
|
517
|
+
f"[dim]{e.requests} requests -> {e.endpoints} endpoint"
|
|
518
|
+
f"{'' if e.endpoints == 1 else 's'} in {e.seconds:.1f}s[/]"
|
|
519
|
+
)
|
|
520
|
+
if self.client is not None:
|
|
521
|
+
print_client(self.console, self.client)
|
|
522
|
+
if e.lessons_learned:
|
|
523
|
+
self.console.print(
|
|
524
|
+
f"[dim]Remembered {e.lessons_learned} new lesson(s) for next time[/]"
|
|
525
|
+
)
|
|
526
|
+
self.console.print(Text("Report: ", style="dim") + file_link(e.report_html or e.report))
|
|
527
|
+
if self.usage is not None:
|
|
528
|
+
u = self.usage
|
|
529
|
+
providers = ", ".join(f"{k}: {v}" for k, v in u.by_provider.items()) or "-"
|
|
530
|
+
self.console.print(
|
|
531
|
+
f"[dim]LLM: {u.answered} answered calls ({providers}), {u.tokens:,} tokens, "
|
|
532
|
+
f"{u.cache_hits} cached, {u.retries} retries, {u.fallbacks} fallbacks[/]"
|
|
533
|
+
)
|
|
534
|
+
for problem in u.problems:
|
|
535
|
+
self.console.print(f"[dim] {problem}[/]")
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def print_client(console: Console, c: ClientGenerated) -> None:
|
|
539
|
+
how = "names chosen by the LLM" if c.named_by_llm else "names derived from URLs"
|
|
540
|
+
console.print(f"\n[bold green]Generated[/] {c.class_name} ({how}) in {c.directory}")
|
|
541
|
+
for m in c.methods:
|
|
542
|
+
extra = f" + {m['iterator']}()" if m.get("iterator") else ""
|
|
543
|
+
console.print(f" {m['name']}({', '.join(m['args'])}) -> {m['returns']}{extra}")
|
|
544
|
+
for cred in c.credentials:
|
|
545
|
+
console.print(
|
|
546
|
+
f" [yellow]needs[/] {cred['arg']} (set {cred['env']} or pass it to the client)"
|
|
547
|
+
)
|
|
548
|
+
for skipped in c.skipped:
|
|
549
|
+
console.print(f" [dim]skipped {skipped}[/]")
|
|
550
|
+
console.print(f"[dim]Files: {', '.join(c.files)}[/]")
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _use_it(c: ClientGenerated) -> str:
|
|
554
|
+
if not c.methods:
|
|
555
|
+
return ""
|
|
556
|
+
module = c.files[0].rsplit(".", 1)[0] if c.files else "client"
|
|
557
|
+
first = c.methods[0]
|
|
558
|
+
if c.lang == "typescript":
|
|
559
|
+
call = f"await new {c.class_name}().{first['name']}(...)"
|
|
560
|
+
return f'import {{ {c.class_name} }} from "./{module}.ts"; {call}'
|
|
561
|
+
return f"from {module} import {c.class_name}; {c.class_name}().{first['name']}(...)"
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _count(n: int, word: str) -> str:
|
|
565
|
+
return f"{n} {word}{'' if n == 1 else 's'}"
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _short(url: str, width: int = 80) -> str:
|
|
569
|
+
from urllib.parse import urlsplit
|
|
570
|
+
|
|
571
|
+
parts = urlsplit(url)
|
|
572
|
+
text = parts.path + (f"?{parts.query}" if parts.query else "")
|
|
573
|
+
return text if len(text) <= width else text[: width - 3] + "..."
|
|
File without changes
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Work out why a generated test failed (FR-33)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
FailureKind = Literal[
|
|
9
|
+
"auth",
|
|
10
|
+
"not-found",
|
|
11
|
+
"schema",
|
|
12
|
+
"not-json",
|
|
13
|
+
"rate-limited",
|
|
14
|
+
"network",
|
|
15
|
+
"missing-credential",
|
|
16
|
+
"unknown",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
_HTTP_RE = re.compile(r"HTTP (\d{3})")
|
|
20
|
+
# Real network failures by their exception names. A bare "Timeout" also appears in the
|
|
21
|
+
# client source shown in tracebacks (httpx.Timeout), so it would match every failure.
|
|
22
|
+
_NETWORK_RE = re.compile(
|
|
23
|
+
r"network error|ConnectError|ConnectTimeout|ReadTimeout|WriteTimeout|PoolTimeout"
|
|
24
|
+
r"|TimeoutException|RemoteProtocolError|timed out"
|
|
25
|
+
)
|
|
26
|
+
# pydantic's error layout: a dotted location line, then an indented explanation line.
|
|
27
|
+
_PYTEST_PREFIX_RE = re.compile(r"^E ", re.M)
|
|
28
|
+
_LOC_RE = re.compile(r"^([A-Za-z0-9_.\-\[\]]+)\n {2}\S", re.M)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def classify(message: str) -> FailureKind:
|
|
32
|
+
if "Missing credential" in message:
|
|
33
|
+
return "missing-credential"
|
|
34
|
+
if "validation error" in message and "ValidationError" in message:
|
|
35
|
+
return "schema"
|
|
36
|
+
if "response is not JSON" in message:
|
|
37
|
+
return "not-json"
|
|
38
|
+
status = _HTTP_RE.search(message)
|
|
39
|
+
if status:
|
|
40
|
+
code = int(status.group(1))
|
|
41
|
+
if code in (401, 403):
|
|
42
|
+
return "auth"
|
|
43
|
+
if code in (404, 410):
|
|
44
|
+
return "not-found"
|
|
45
|
+
if code == 429:
|
|
46
|
+
return "rate-limited"
|
|
47
|
+
if code >= 500:
|
|
48
|
+
return "network"
|
|
49
|
+
if _NETWORK_RE.search(message):
|
|
50
|
+
return "network"
|
|
51
|
+
return "unknown"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def validation_locations(message: str) -> list[tuple[str, ...]]:
|
|
55
|
+
"""Field paths from a pydantic ValidationError, e.g. ('quotes', '0', 'author', 'name')."""
|
|
56
|
+
if "validation error" not in message:
|
|
57
|
+
return []
|
|
58
|
+
message = _PYTEST_PREFIX_RE.sub("", message) # pytest marks error lines with "E "
|
|
59
|
+
body = message.split("validation error", 1)[1]
|
|
60
|
+
locs = []
|
|
61
|
+
for match in _LOC_RE.finditer(body):
|
|
62
|
+
loc = match.group(1)
|
|
63
|
+
if loc.startswith(("For further", "Input")) or loc in ("E", "-"):
|
|
64
|
+
continue
|
|
65
|
+
locs.append(tuple(loc.split(".")))
|
|
66
|
+
return list(dict.fromkeys(locs))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def short_reason(kind: FailureKind, message: str) -> str:
|
|
70
|
+
status = _HTTP_RE.search(message)
|
|
71
|
+
labels = {
|
|
72
|
+
"auth": "the API refused the request (login or key needed)",
|
|
73
|
+
"not-found": "the API said not found",
|
|
74
|
+
"schema": "the response did not match the generated model",
|
|
75
|
+
"not-json": "the response was not JSON",
|
|
76
|
+
"rate-limited": "rate-limited by the API",
|
|
77
|
+
"network": "network or server error",
|
|
78
|
+
"missing-credential": "a credential is needed",
|
|
79
|
+
"unknown": "unexpected failure",
|
|
80
|
+
}
|
|
81
|
+
text = labels[kind]
|
|
82
|
+
if status and kind != "schema":
|
|
83
|
+
text += f" (HTTP {status.group(1)})"
|
|
84
|
+
return text
|