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
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>&gt; </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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")