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/repl.py
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
"""The interactive session: `ghost-api` with no command, like Claude Code or Gemini CLI.
|
|
2
|
+
|
|
3
|
+
Paste a website and what you want ("quotes.toscrape.com/scroll list quotes with authors"),
|
|
4
|
+
watch it work, then keep going: download the data, set up alerts, open the report, try
|
|
5
|
+
another site. The session ends only on /exit or Ctrl+C pressed twice; a single Ctrl+C
|
|
6
|
+
stops what is running and brings the prompt back.
|
|
7
|
+
|
|
8
|
+
`Repl.handle()` turns one line into actions (and is what the tests drive); `run_session()`
|
|
9
|
+
adds the input box: history, slash-command completion and the hints line.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import shlex
|
|
17
|
+
import time
|
|
18
|
+
import webbrowser
|
|
19
|
+
from collections.abc import Callable
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from rich.console import Console, Group
|
|
24
|
+
from rich.panel import Panel
|
|
25
|
+
from rich.table import Table
|
|
26
|
+
from rich.text import Text
|
|
27
|
+
|
|
28
|
+
from ghost_api import __version__
|
|
29
|
+
from ghost_api.site import site_key
|
|
30
|
+
from ghost_api.ui.terminal import ACCENT, file_link, file_url, ghost_avatar
|
|
31
|
+
|
|
32
|
+
EXIT_WINDOW_S = 2.0 # Ctrl+C twice within this many seconds ends the session
|
|
33
|
+
URL_RE = re.compile(
|
|
34
|
+
r"(?<![\w@])((?:https?://)?(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d+)?(?:/[^\s]*)?)", re.I
|
|
35
|
+
)
|
|
36
|
+
FILE_SUFFIXES = (".py", ".md", ".json", ".csv", ".html", ".txt", ".xlsx")
|
|
37
|
+
LOCAL_URL_RE = re.compile(r"(?<![\w@])(https?://(?:localhost|127\.0\.0\.1)(?::\d+)?(?:/\S*)?)")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Command:
|
|
42
|
+
name: str
|
|
43
|
+
usage: str
|
|
44
|
+
help: str
|
|
45
|
+
cli: str | None = None # the `ghost-api` command it runs, if any
|
|
46
|
+
needs_site: bool = False # uses the current site when none is given
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
COMMANDS = [
|
|
50
|
+
Command("/help", "/help", "Show commands and tips"),
|
|
51
|
+
Command("/sites", "/sites", "Sites you've explored", cli="inspect"),
|
|
52
|
+
Command("/fetch", "/fetch [SITE] [OPERATION] [name=value ...] [--format xlsx]",
|
|
53
|
+
"Download data as CSV, Excel or JSON (no code)", cli="fetch", needs_site=True),
|
|
54
|
+
Command("/watch", "/watch add|list|run|changes|remove ...",
|
|
55
|
+
"Alerts when items appear, change or disappear", cli="watch"),
|
|
56
|
+
Command("/report", "/report [SITE]", "Write the site's report and link it",
|
|
57
|
+
cli="report", needs_site=True),
|
|
58
|
+
Command("/open", "/open [SITE]", "Open the site's report in your browser"),
|
|
59
|
+
Command("/import", "/import FILE.har [--offline]",
|
|
60
|
+
"Map an API from a HAR file you saved in your browser", cli="import"),
|
|
61
|
+
Command("/refresh", "/refresh [SITE]", "Re-check a known site from memory",
|
|
62
|
+
cli="refresh", needs_site=True),
|
|
63
|
+
Command("/diff", "/diff [SITE] [--live] [--history]",
|
|
64
|
+
"What changed in the site's API since the last client version",
|
|
65
|
+
cli="diff", needs_site=True),
|
|
66
|
+
Command("/explain", "/explain [SITE]", "A plain-language summary of a site's API",
|
|
67
|
+
cli="explain", needs_site=True),
|
|
68
|
+
Command("/generate", "/generate [SITE] [--lang typescript]", "Regenerate the client",
|
|
69
|
+
cli="generate", needs_site=True),
|
|
70
|
+
Command("/serve", "/serve [SITE]", "Serve sites as a local REST API (Ctrl+C stops it)",
|
|
71
|
+
cli="serve"),
|
|
72
|
+
Command("/mock", "/mock [SITE]", "Offline copy of the site's API (Ctrl+C stops it)",
|
|
73
|
+
cli="mock", needs_site=True),
|
|
74
|
+
Command("/ui", "/ui", "Open the web dashboard (Ctrl+C stops it)", cli="ui"),
|
|
75
|
+
Command("/inspect", "/inspect [SITE] [--run N]", "What's stored about a site or a run",
|
|
76
|
+
cli="inspect"),
|
|
77
|
+
Command("/doctor", "/doctor", "Check your LLM keys", cli="doctor"),
|
|
78
|
+
Command("/login", "/login URL | --list", "Log in to a site once; runs reuse the session",
|
|
79
|
+
cli="login"),
|
|
80
|
+
Command("/forget", "/forget SITE [--llm-cache]", "Delete everything stored about a site",
|
|
81
|
+
cli="forget"),
|
|
82
|
+
Command("/lang", "/lang python|typescript", "Client language for new runs"),
|
|
83
|
+
Command("/agent", "/agent on|off", "Use the AI agent for new runs, or just load the page"),
|
|
84
|
+
Command("/clear", "/clear", "Clear the screen"),
|
|
85
|
+
Command("/exit", "/exit", "End the session (or press Ctrl+C twice)"),
|
|
86
|
+
] # fmt: skip
|
|
87
|
+
BY_NAME = {c.name: c for c in COMMANDS} | {"/quit": COMMANDS[-1], "/q": COMMANDS[-1]}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Repl:
|
|
91
|
+
def __init__(
|
|
92
|
+
self,
|
|
93
|
+
console: Console,
|
|
94
|
+
invoke: Callable[[list[str]], None],
|
|
95
|
+
ask: Callable[[str], str],
|
|
96
|
+
out: Path = Path("output"),
|
|
97
|
+
open_url: Callable[[str], object] = webbrowser.open,
|
|
98
|
+
) -> None:
|
|
99
|
+
self.console = console
|
|
100
|
+
self.invoke = invoke # runs `ghost-api ARGS...` in this process
|
|
101
|
+
self.ask = ask
|
|
102
|
+
self.out = out
|
|
103
|
+
self.open_url = open_url
|
|
104
|
+
self.lang = "python"
|
|
105
|
+
self.agent = True
|
|
106
|
+
self.site: str | None = None # the current site
|
|
107
|
+
self.url: str | None = None
|
|
108
|
+
|
|
109
|
+
# --- one line ---
|
|
110
|
+
|
|
111
|
+
def handle(self, line: str) -> bool:
|
|
112
|
+
"""Do what the line says. Returns False when the session should end."""
|
|
113
|
+
line = line.strip()
|
|
114
|
+
if not line:
|
|
115
|
+
return True
|
|
116
|
+
if line.startswith("/"):
|
|
117
|
+
return self._command(line)
|
|
118
|
+
url = _find_url(line)
|
|
119
|
+
if url:
|
|
120
|
+
goal = line.replace(url, " ").strip(" -:·,")
|
|
121
|
+
return self._explore(url, goal)
|
|
122
|
+
if self.url: # more words about the current site: a new goal for it
|
|
123
|
+
self.console.print(Text(f"New goal for {self.site}: {line}", style="dim"))
|
|
124
|
+
return self._explore(self.url, line)
|
|
125
|
+
self.console.print(
|
|
126
|
+
"Paste a website and what you want from it, e.g. "
|
|
127
|
+
"[bold]quotes.toscrape.com/scroll list quotes with their authors[/], "
|
|
128
|
+
"or type [bold]/help[/]."
|
|
129
|
+
)
|
|
130
|
+
return True
|
|
131
|
+
|
|
132
|
+
def _explore(self, url: str, goal: str) -> bool:
|
|
133
|
+
if "://" not in url:
|
|
134
|
+
url = "https://" + url
|
|
135
|
+
if not goal:
|
|
136
|
+
goal = self.ask("What do you want from this site? ").strip()
|
|
137
|
+
if not goal:
|
|
138
|
+
self.console.print("[dim]No goal given; nothing started.[/]")
|
|
139
|
+
return True
|
|
140
|
+
self.url, self.site = url, site_key(url)
|
|
141
|
+
args = ["run", url, "--goal", goal, "--lang", self.lang]
|
|
142
|
+
if not self.agent:
|
|
143
|
+
args.append("--no-agent")
|
|
144
|
+
self.invoke(args)
|
|
145
|
+
self._after_run()
|
|
146
|
+
return True
|
|
147
|
+
|
|
148
|
+
def _after_run(self) -> None:
|
|
149
|
+
report = self._report_path()
|
|
150
|
+
if report is not None:
|
|
151
|
+
self.console.print(
|
|
152
|
+
Text("Report: ", style="bold") + file_link(report) + Text(" (/open)", style="dim")
|
|
153
|
+
)
|
|
154
|
+
self.console.print(
|
|
155
|
+
Text(
|
|
156
|
+
f"Next: /fetch to download {self.site}'s data · /watch add for alerts · "
|
|
157
|
+
"another URL to explore · /exit to finish",
|
|
158
|
+
style="dim",
|
|
159
|
+
) # fmt: skip
|
|
160
|
+
)
|
|
161
|
+
else:
|
|
162
|
+
# No report generated — likely no endpoints found
|
|
163
|
+
self.console.print(
|
|
164
|
+
Text(
|
|
165
|
+
"No endpoints found this time. Try a different goal, or paste a new URL. "
|
|
166
|
+
"Type /doctor to check your LLM keys.",
|
|
167
|
+
style="yellow",
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
self.console.print(
|
|
171
|
+
Text(
|
|
172
|
+
f"Next: /fetch {self.site} to see what's available · another URL to explore "
|
|
173
|
+
"· /exit to finish",
|
|
174
|
+
style="dim",
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def _command(self, line: str) -> bool:
|
|
179
|
+
name, _, rest = line.partition(" ")
|
|
180
|
+
command = BY_NAME.get(name.lower())
|
|
181
|
+
if command is None:
|
|
182
|
+
from difflib import get_close_matches
|
|
183
|
+
|
|
184
|
+
close = get_close_matches(name.lower(), [c.name for c in COMMANDS], n=3, cutoff=0.5)
|
|
185
|
+
hint = f" Did you mean {', '.join(close)}?" if close else ""
|
|
186
|
+
self.console.print(f"Unknown command {name}.{hint} Type /help for the list.")
|
|
187
|
+
return True
|
|
188
|
+
if command.name == "/exit":
|
|
189
|
+
return False
|
|
190
|
+
if command.name == "/help":
|
|
191
|
+
self.console.print(help_panel())
|
|
192
|
+
elif command.name == "/clear":
|
|
193
|
+
self.console.clear()
|
|
194
|
+
elif command.name == "/lang":
|
|
195
|
+
self._set_lang(rest.strip())
|
|
196
|
+
elif command.name == "/agent":
|
|
197
|
+
self._set_agent(rest.strip().lower())
|
|
198
|
+
elif command.name == "/open":
|
|
199
|
+
self._open(rest.strip())
|
|
200
|
+
elif command.cli:
|
|
201
|
+
self._run_cli(command, rest)
|
|
202
|
+
return True
|
|
203
|
+
|
|
204
|
+
def _run_cli(self, command: Command, rest: str) -> None:
|
|
205
|
+
try:
|
|
206
|
+
args = shlex.split(rest, posix=os.name != "nt")
|
|
207
|
+
except ValueError as exc:
|
|
208
|
+
self.console.print(f"Couldn't read that: {exc}")
|
|
209
|
+
return
|
|
210
|
+
args = [a.strip('"') if os.name == "nt" else a for a in args]
|
|
211
|
+
if command.needs_site and not _starts_with_site(args):
|
|
212
|
+
if self.site is None:
|
|
213
|
+
self.console.print(f"Which site? Usage: {command.usage}")
|
|
214
|
+
return
|
|
215
|
+
args = [self.site, *args]
|
|
216
|
+
if command.name == "/watch" and not args:
|
|
217
|
+
args = ["list"]
|
|
218
|
+
if command.needs_site and args and "://" in args[0]:
|
|
219
|
+
site = site_key(args[0])
|
|
220
|
+
if not (self.out / site).exists():
|
|
221
|
+
self.console.print(
|
|
222
|
+
f"{site} hasn't been explored yet. To explore it, paste the URL with what "
|
|
223
|
+
f"you want (no /command), e.g. [bold]{args[0]} list the headlines[/]"
|
|
224
|
+
)
|
|
225
|
+
return
|
|
226
|
+
args = [site, *args[1:]]
|
|
227
|
+
if args and command.needs_site:
|
|
228
|
+
self.site = args[0] if "/" in args[0] or "." in args[0] else self.site
|
|
229
|
+
self.invoke([command.cli or "", *args])
|
|
230
|
+
|
|
231
|
+
def _set_lang(self, lang: str) -> None:
|
|
232
|
+
if lang not in ("python", "typescript"):
|
|
233
|
+
self.console.print(
|
|
234
|
+
f"Client language is {self.lang}. Use /lang python or /lang typescript."
|
|
235
|
+
)
|
|
236
|
+
return
|
|
237
|
+
self.lang = lang
|
|
238
|
+
self.console.print(f"New runs will generate a {lang} client.")
|
|
239
|
+
|
|
240
|
+
def _set_agent(self, value: str) -> None:
|
|
241
|
+
if value not in ("on", "off"):
|
|
242
|
+
state = "on" if self.agent else "off"
|
|
243
|
+
self.console.print(f"The AI agent is {state}. Use /agent on or /agent off.")
|
|
244
|
+
return
|
|
245
|
+
self.agent = value == "on"
|
|
246
|
+
self.console.print(
|
|
247
|
+
"New runs use the AI agent."
|
|
248
|
+
if self.agent
|
|
249
|
+
else "New runs only load and scroll the page (no LLM calls)." # fmt: skip
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
def _open(self, site: str) -> None:
|
|
253
|
+
if site:
|
|
254
|
+
self.site = site
|
|
255
|
+
report = self._report_path()
|
|
256
|
+
if report is None:
|
|
257
|
+
self.console.print("No report yet. Explore a site first (paste its URL).")
|
|
258
|
+
return
|
|
259
|
+
self.open_url(file_url(report))
|
|
260
|
+
self.console.print(Text("Opened ", style="dim") + file_link(report))
|
|
261
|
+
|
|
262
|
+
def _report_path(self) -> Path | None:
|
|
263
|
+
if not self.site:
|
|
264
|
+
return None
|
|
265
|
+
for name in ("report.html", "report.md"):
|
|
266
|
+
path = self.out / self.site / name
|
|
267
|
+
if path.exists():
|
|
268
|
+
return path
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _find_url(line: str) -> str | None:
|
|
273
|
+
local = LOCAL_URL_RE.search(line)
|
|
274
|
+
if local:
|
|
275
|
+
return local.group(1)
|
|
276
|
+
for match in URL_RE.finditer(line):
|
|
277
|
+
if line[match.end() : match.end() + 1] == "@":
|
|
278
|
+
continue # the name part of an email address (john.doe@example.com)
|
|
279
|
+
candidate = match.group(1).rstrip(".,;)")
|
|
280
|
+
host = candidate.split("://")[-1].split("/")[0].split(":")[0]
|
|
281
|
+
is_file = host.endswith(FILE_SUFFIXES)
|
|
282
|
+
if "." in host and not host.replace(".", "").isdigit() and not is_file:
|
|
283
|
+
return candidate
|
|
284
|
+
return None
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _starts_with_site(args: list[str]) -> bool:
|
|
288
|
+
return bool(args) and not args[0].startswith("-") and ("." in args[0] or "://" in args[0])
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# --- the screen -----------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def welcome_panel(cwd: Path, has_llm: bool) -> Panel:
|
|
295
|
+
text = Group(
|
|
296
|
+
Text.assemble(("Welcome to Ghost API", "bold"), (f" v{__version__}", "dim")),
|
|
297
|
+
Text(""),
|
|
298
|
+
Text(" Paste a website and say what data you want.", style="dim"),
|
|
299
|
+
Text(" /help for commands · Ctrl+C twice to exit", style="dim"),
|
|
300
|
+
Text(""),
|
|
301
|
+
Text(" AI agent: ready", style="dim")
|
|
302
|
+
if has_llm
|
|
303
|
+
else Text(" AI agent: no LLM key in .env (pages are only loaded and scrolled)",
|
|
304
|
+
style="yellow"), # fmt: skip
|
|
305
|
+
) # fmt: skip
|
|
306
|
+
body = Table.grid(padding=(0, 2))
|
|
307
|
+
body.add_row(ghost_avatar(), text)
|
|
308
|
+
return Panel(body, border_style=ACCENT, padding=(0, 1), expand=False)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def tips() -> Text:
|
|
312
|
+
return Text.assemble(
|
|
313
|
+
("Tips for getting started:\n", "bold"),
|
|
314
|
+
("1. ", "dim"), ("quotes.toscrape.com/scroll list quotes with their authors\n", ""),
|
|
315
|
+
("2. ", "dim"), ("/fetch", ACCENT), (" to download a site's data, ", ""),
|
|
316
|
+
("/watch add", ACCENT), (" for alerts\n", ""),
|
|
317
|
+
("3. ", "dim"), ("/ui", ACCENT), (" opens the dashboard, ", ""),
|
|
318
|
+
("/sites", ACCENT), (" lists what you've explored", ""),
|
|
319
|
+
) # fmt: skip
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def help_panel() -> Panel:
|
|
323
|
+
table = Table.grid(padding=(0, 2))
|
|
324
|
+
table.add_column(style=f"bold {ACCENT}", no_wrap=True)
|
|
325
|
+
table.add_column()
|
|
326
|
+
for c in COMMANDS:
|
|
327
|
+
table.add_row(c.name, Text.assemble((c.help, ""), (f"\n{c.usage}", "dim")))
|
|
328
|
+
body = Group(
|
|
329
|
+
Text(
|
|
330
|
+
"Paste a URL and a goal to explore a site. More words after a run give it a new "
|
|
331
|
+
"goal on the same site.\n",
|
|
332
|
+
style="dim",
|
|
333
|
+
), # fmt: skip
|
|
334
|
+
table,
|
|
335
|
+
Text(
|
|
336
|
+
"\nCtrl+C stops what's running. Ctrl+C twice (or /exit) ends the session. "
|
|
337
|
+
"Up/down arrows: history. Tab: complete a command.",
|
|
338
|
+
style="dim",
|
|
339
|
+
), # fmt: skip
|
|
340
|
+
)
|
|
341
|
+
return Panel(body, title="Commands", title_align="left", border_style=ACCENT, padding=(1, 2))
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
# --- the input loop -------------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def run_session(
|
|
348
|
+
console: Console,
|
|
349
|
+
repl: Repl,
|
|
350
|
+
has_llm: bool | Callable[[], bool],
|
|
351
|
+
history_file: Path | None = None,
|
|
352
|
+
prompt_input: object | None = None,
|
|
353
|
+
prompt_output: object | None = None,
|
|
354
|
+
) -> None:
|
|
355
|
+
"""Read lines until /exit or Ctrl+C twice. `prompt_input`/`prompt_output` are for tests."""
|
|
356
|
+
from prompt_toolkit import PromptSession
|
|
357
|
+
from prompt_toolkit.completion import Completer, Completion
|
|
358
|
+
from prompt_toolkit.formatted_text import HTML
|
|
359
|
+
from prompt_toolkit.history import FileHistory, InMemoryHistory
|
|
360
|
+
from prompt_toolkit.styles import Style
|
|
361
|
+
|
|
362
|
+
from ghost_api.ui import keys
|
|
363
|
+
|
|
364
|
+
class SlashCompleter(Completer):
|
|
365
|
+
def get_completions(self, document, complete_event): # type: ignore[no-untyped-def]
|
|
366
|
+
word = document.text_before_cursor
|
|
367
|
+
if not word.startswith("/") or " " in word:
|
|
368
|
+
return
|
|
369
|
+
for c in COMMANDS:
|
|
370
|
+
if c.name.startswith(word):
|
|
371
|
+
yield Completion(c.name, start_position=-len(word), display_meta=c.help)
|
|
372
|
+
|
|
373
|
+
if history_file is not None:
|
|
374
|
+
history_file.parent.mkdir(parents=True, exist_ok=True)
|
|
375
|
+
session: PromptSession[str] = PromptSession(
|
|
376
|
+
history=FileHistory(str(history_file)) if history_file else InMemoryHistory(),
|
|
377
|
+
completer=SlashCompleter(),
|
|
378
|
+
complete_while_typing=True,
|
|
379
|
+
style=Style.from_dict({"rule": "#5a5a70", "prompt": f"{ACCENT} bold", "hint": "#8a8aa0"}),
|
|
380
|
+
input=prompt_input, # type: ignore[arg-type]
|
|
381
|
+
output=prompt_output, # type: ignore[arg-type]
|
|
382
|
+
)
|
|
383
|
+
state = {"armed": 0.0}
|
|
384
|
+
|
|
385
|
+
def toolbar() -> HTML:
|
|
386
|
+
width = max(20, console.width)
|
|
387
|
+
if time.monotonic() - state["armed"] < EXIT_WINDOW_S:
|
|
388
|
+
hint = "Press Ctrl+C again to exit"
|
|
389
|
+
else:
|
|
390
|
+
site = f" · site: {repl.site}" if repl.site else ""
|
|
391
|
+
agent = "" if repl.agent else " · agent off"
|
|
392
|
+
hint = f"/help for commands · {repl.lang}{agent}{site} · Ctrl+C twice to exit"
|
|
393
|
+
return HTML(f"<rule>{'─' * width}</rule>\n<hint> {_escape(hint)}</hint>")
|
|
394
|
+
|
|
395
|
+
from ghost_api.ui import terminal
|
|
396
|
+
|
|
397
|
+
def ask(question: str) -> str:
|
|
398
|
+
"""A follow-up question, in the same input box (Backspace, arrows... all work).
|
|
399
|
+
Ctrl+C or an empty answer cancels."""
|
|
400
|
+
try:
|
|
401
|
+
return session.prompt(
|
|
402
|
+
HTML(f"<prompt>{_escape(question)}</prompt>"),
|
|
403
|
+
bottom_toolbar=HTML("<hint> Ctrl+C to cancel</hint>"),
|
|
404
|
+
)
|
|
405
|
+
except (KeyboardInterrupt, EOFError):
|
|
406
|
+
return ""
|
|
407
|
+
|
|
408
|
+
plain_invoke = repl.invoke
|
|
409
|
+
|
|
410
|
+
def invoke(args: list[str]) -> None:
|
|
411
|
+
# Only while something runs: Ctrl+C becomes a key (see ui/keys.py).
|
|
412
|
+
terminal.PROGRESS.update(site="", step=0, endpoints=0)
|
|
413
|
+
with keys.ctrl_c_as_key():
|
|
414
|
+
plain_invoke(args)
|
|
415
|
+
|
|
416
|
+
plain_ask = repl.ask
|
|
417
|
+
repl.ask, repl.invoke = ask, invoke
|
|
418
|
+
keys.on_stop_requested = lambda message: console.print(f"[yellow]{message}[/]")
|
|
419
|
+
terminal.SESSION["active"] = True
|
|
420
|
+
try:
|
|
421
|
+
console.print(welcome_panel(Path.cwd(), has_llm() if callable(has_llm) else has_llm))
|
|
422
|
+
console.print(tips())
|
|
423
|
+
while True:
|
|
424
|
+
console.print()
|
|
425
|
+
try:
|
|
426
|
+
line = session.prompt(
|
|
427
|
+
HTML(f"<rule>{'─' * max(20, console.width)}</rule>\n<prompt>> </prompt>"),
|
|
428
|
+
bottom_toolbar=toolbar,
|
|
429
|
+
refresh_interval=0.5,
|
|
430
|
+
)
|
|
431
|
+
except KeyboardInterrupt:
|
|
432
|
+
if time.monotonic() - state["armed"] < EXIT_WINDOW_S:
|
|
433
|
+
break
|
|
434
|
+
state["armed"] = time.monotonic()
|
|
435
|
+
continue
|
|
436
|
+
except EOFError: # Ctrl+D / Ctrl+Z, or the input closed
|
|
437
|
+
break
|
|
438
|
+
state["armed"] = 0.0
|
|
439
|
+
try:
|
|
440
|
+
keep_going = repl.handle(line)
|
|
441
|
+
except KeyboardInterrupt:
|
|
442
|
+
console.print(f"[yellow]{terminal.stop_message(repl.site or '')}[/]")
|
|
443
|
+
keep_going = True
|
|
444
|
+
if not keep_going:
|
|
445
|
+
break
|
|
446
|
+
finally:
|
|
447
|
+
terminal.SESSION["active"] = False
|
|
448
|
+
repl.ask, repl.invoke = plain_ask, plain_invoke
|
|
449
|
+
console.print(Text("Goodbye. Your results are in ", style="dim") + file_link(repl.out))
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _escape(text: str) -> str:
|
|
453
|
+
return text.replace("&", "&").replace("<", "<").replace(">", ">")
|