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/cli/data.py ADDED
@@ -0,0 +1,492 @@
1
+ """Commands that use a site's data: fetch, watch, serve, mcp, ui."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ import time
7
+ from dataclasses import asdict
8
+ from pathlib import Path
9
+ from typing import Annotated, Any
10
+
11
+ import typer
12
+ from rich.console import Console
13
+ from rich.table import Table
14
+ from rich.text import Text
15
+
16
+ from ghost_api import watch
17
+ from ghost_api.cli._common import (
18
+ SITE_HELP,
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 import tabular
30
+ from ghost_api.export.tabular import FORMATS
31
+ from ghost_api.memory.store import Store
32
+ from ghost_api.probe.replay import ReplayRefused
33
+ from ghost_api.runtime import OperationError, SiteRuntime
34
+ from ghost_api.ui.terminal import (
35
+ ACCENT,
36
+ file_link,
37
+ )
38
+
39
+
40
+ @app.command()
41
+ def ui(
42
+ port: Annotated[int, typer.Option(help="Port for the local dashboard.")] = 8800,
43
+ no_open: Annotated[
44
+ bool, typer.Option("--no-open", help="Don't open the browser automatically.")
45
+ ] = False,
46
+ out: Annotated[Path, typer.Option(help="Folder for generated clients.")] = Path("output"),
47
+ db: Annotated[Path | None, typer.Option(help="SQLite database path.")] = None,
48
+ no_watch: Annotated[
49
+ bool, typer.Option("--no-watch", help="Don't check due watches while open.")
50
+ ] = False,
51
+ ) -> None:
52
+ """Open the web dashboard: start runs, watch the agent live, browse every result."""
53
+ from ghost_api.web.server import serve
54
+
55
+ serve(
56
+ db or default_db_path(),
57
+ out,
58
+ port=port,
59
+ open_browser=not no_open,
60
+ console=console,
61
+ run_watches=not no_watch,
62
+ )
63
+
64
+
65
+ def _params(pairs: list[str]) -> dict[str, str]:
66
+ """['area=velachery', 'sport=football'] -> {'area': 'velachery', 'sport': 'football'}."""
67
+ params = {}
68
+ for pair in pairs:
69
+ name, sep, value = pair.partition("=")
70
+ if not sep or not name.strip():
71
+ console.print(f"Inputs look like name=value, not {pair!r}.")
72
+ raise typer.Exit(code=2)
73
+ params[name.strip()] = value
74
+ return params
75
+
76
+
77
+ def _operation_json(op: Any) -> dict[str, Any]:
78
+ return {
79
+ "name": op.name,
80
+ "summary": op.summary,
81
+ "endpoint": op.label,
82
+ "inputs": [asdict(i) for i in op.inputs],
83
+ "pagination": op.pagination,
84
+ }
85
+
86
+
87
+ def _operations_table(rt: SiteRuntime) -> Table:
88
+ table = Table(title=f"What you can fetch from {rt.site}", title_justify="left")
89
+ for col in ("Operation", "What it returns", "Inputs (example)", "Pages"):
90
+ table.add_column(col, overflow="fold")
91
+ for op in rt.operations():
92
+ inputs = ", ".join(f"{i.name}={i.example}" if i.example else i.name for i in op.inputs)
93
+ pages = op.pagination["style"] if op.pagination else "-"
94
+ table.add_row(f"[bold {ACCENT}]{op.name}[/]", op.summary, inputs or "-", pages)
95
+ return table
96
+
97
+
98
+ @app.command()
99
+ def fetch(
100
+ domain: Annotated[str, typer.Argument(help=SITE_HELP)],
101
+ operation: Annotated[
102
+ str | None, typer.Argument(help="What to fetch; leave out to list the choices.")
103
+ ] = None,
104
+ inputs: Annotated[
105
+ list[str] | None, typer.Argument(help="Inputs as name=value, e.g. area=velachery.")
106
+ ] = None,
107
+ fmt: Annotated[str, typer.Option("--format", help="csv, xlsx or json.")] = "csv",
108
+ pages: Annotated[int, typer.Option(help="Maximum pages to follow.")] = 5,
109
+ max_rows: Annotated[int, typer.Option(help="Stop after this many rows.")] = 1000,
110
+ save_to: Annotated[
111
+ Path | None, typer.Option("--save-to", help="File to write (default: in output/).")
112
+ ] = None,
113
+ include_sensitive: Annotated[
114
+ bool,
115
+ typer.Option(
116
+ "--include-sensitive",
117
+ help="Keep personal data (emails, phones, IDs). Only for auditing your own site.",
118
+ ),
119
+ ] = False,
120
+ out: Annotated[Path, typer.Option(help="Folder for generated files.")] = Path("output"),
121
+ db: Annotated[Path | None, typer.Option(help="SQLite database path.")] = None,
122
+ ) -> None:
123
+ """Download a site's data as CSV, Excel or JSON, following all pages. No code needed."""
124
+ console.print(f"[dim]⏳ Fetching data from {domain}...[/]")
125
+ started = time.monotonic()
126
+ if fmt not in FORMATS:
127
+ console.print(f"--format must be one of: {', '.join(FORMATS)}")
128
+ raise typer.Exit(code=2)
129
+ path = _need_db(db)
130
+ with Store(path) as store:
131
+ domain = _site(store, domain)
132
+ try:
133
+ rt = SiteRuntime(store, domain, include_sensitive=include_sensitive)
134
+ except OperationError as exc:
135
+ console.print(str(exc))
136
+ raise typer.Exit(code=1) from exc
137
+ with rt:
138
+ if operation is None and _options["json"]:
139
+ json_out({"site": domain, "operations": [_operation_json(o) for o in rt.operations()]})
140
+ return
141
+ if operation is None:
142
+ console.print(_operations_table(rt))
143
+ ops = rt.operations()
144
+ if ops:
145
+ console.print(f"[dim]Example: ghost-api fetch {domain} {ops[0].name}[/]")
146
+ else:
147
+ console.print(
148
+ "[yellow]No fetchable endpoints found.[/] This site's endpoints may use "
149
+ "authentication or change data, so Ghost API won't call them directly.\n"
150
+ "[dim]If you know an endpoint only reads data, re-generate with:\n"
151
+ f' ghost-api generate {domain} --allow "POST /api/search"[/]'
152
+ )
153
+ return
154
+ try:
155
+ op = rt.operation(operation)
156
+ records: list[object] = []
157
+ hidden = 0
158
+ for page in rt.iterate(op, _params(inputs or []), pages, max_rows):
159
+ if not page.result.ok:
160
+ detail = page.result.error or f"HTTP {page.result.status}"
161
+ console.print(f"[yellow]Page {page.number} failed:[/] {detail}")
162
+ break
163
+ records += page.records
164
+ hidden += sum(int(h.get("count", 1)) for h in page.result.hidden)
165
+ console.print(f" Page {page.number}: {len(page.records)} rows")
166
+ except (OperationError, ReplayRefused) as exc:
167
+ console.print(str(exc))
168
+ raise typer.Exit(code=1) from exc
169
+ if not records:
170
+ console.print("No rows came back. Try other inputs (see `ghost-api fetch SITE`).")
171
+ raise typer.Exit(code=1)
172
+ stamp = time.strftime("%Y%m%d-%H%M%S")
173
+ target = save_to or out / domain / "data" / f"{op.name}-{stamp}.{fmt}"
174
+ tabular.write(records, target, fmt)
175
+ elapsed = time.monotonic() - started
176
+ saved = Text(f"Saved {len(records)} rows in {elapsed:.1f}s: ", style="bold green")
177
+ console.print(saved + file_link(target))
178
+ if hidden:
179
+ console.print(f"[dim]{hidden} personal values hidden[/]")
180
+ if _options["json"]:
181
+ json_out(
182
+ {"site": domain, "operation": op.name, "file": str(target), "rows": len(records),
183
+ "personal_values_hidden": hidden, "records": records}
184
+ ) # fmt: skip
185
+
186
+
187
+ watch_app = typer.Typer(
188
+ help="Watch a site for new, changed or removed items, and get alerts.",
189
+ no_args_is_help=True,
190
+ )
191
+ TASK_NAME = "GhostAPI watch"
192
+
193
+
194
+ def _print_check(result: watch.CheckResult) -> None:
195
+ style = "red" if result.error else ("green" if result.changes else "dim")
196
+ console.print(f"[{style}]#{result.watch['id']} {result.headline()}[/]")
197
+ for change in result.changes[: watch.MAX_CHANGES_LISTED]:
198
+ console.print(f" {change.line()}", markup=False)
199
+ if len(result.changes) > watch.MAX_CHANGES_LISTED:
200
+ console.print(f" ... and {len(result.changes) - watch.MAX_CHANGES_LISTED} more")
201
+ for note in result.notified:
202
+ console.print(f" [dim]notify {note}[/]")
203
+
204
+
205
+ @watch_app.command("add")
206
+ def watch_add(
207
+ domain: Annotated[str, typer.Argument(help=SITE_HELP)],
208
+ operation: Annotated[str, typer.Argument(help="What to watch (see `ghost-api fetch SITE`).")],
209
+ inputs: Annotated[
210
+ list[str] | None, typer.Argument(help="Inputs as name=value, e.g. area=velachery.")
211
+ ] = None,
212
+ every: Annotated[str, typer.Option(help="How often to check: 30m, 6h, 1d...")] = "6h",
213
+ notify: Annotated[
214
+ list[str] | None,
215
+ typer.Option(help="ntfy:TOPIC, webhook:URL or desktop. Repeatable."),
216
+ ] = None,
217
+ track: Annotated[
218
+ str | None,
219
+ typer.Option(help="Only these fields count as changes, e.g. price,name (default: all)."),
220
+ ] = None,
221
+ pages: Annotated[int, typer.Option(help="Pages to read on each check.")] = 5,
222
+ no_check: Annotated[
223
+ bool, typer.Option("--no-check", help="Don't take the first snapshot now.")
224
+ ] = False,
225
+ db: Annotated[Path | None, typer.Option(help="SQLite database path.")] = None,
226
+ ) -> None:
227
+ """Start watching an endpoint. The first check records what's there now."""
228
+ try:
229
+ minutes = watch.parse_every(every)
230
+ except ValueError as exc:
231
+ console.print(str(exc))
232
+ raise typer.Exit(code=2) from exc
233
+ path = _need_db(db)
234
+ with Store(path) as store:
235
+ domain = _site(store, domain)
236
+ try:
237
+ with SiteRuntime(store, domain) as rt:
238
+ op = rt.operation(operation)
239
+ except OperationError as exc:
240
+ console.print(str(exc))
241
+ raise typer.Exit(code=1) from exc
242
+ watch_id = store.add_watch(
243
+ {
244
+ "site": domain,
245
+ "operation": op.name,
246
+ "params": _params(inputs or []),
247
+ "every_minutes": minutes,
248
+ "notify": notify or [],
249
+ "track": [t.strip() for t in (track or "").split(",") if t.strip()],
250
+ "max_pages": pages,
251
+ }
252
+ )
253
+ console.print(
254
+ f"Watching #{watch_id}: {domain} {op.name}, every {watch.every_label(minutes)}."
255
+ )
256
+ if not no_check:
257
+ w = store.get_watch(watch_id)
258
+ assert w is not None
259
+ _print_check(watch.check(store, w))
260
+ console.print(
261
+ "[dim]Checks run while `ghost-api watch start` or `ghost-api ui` is open, or in the "
262
+ "background after `ghost-api watch schedule`.[/]"
263
+ )
264
+
265
+
266
+ @watch_app.command("list")
267
+ def watch_list(
268
+ db: Annotated[Path | None, typer.Option(help="SQLite database path.")] = None,
269
+ ) -> None:
270
+ """Show your watches."""
271
+ with Store(_need_db(db)) as store:
272
+ rows = store.watches()
273
+ if _options["json"]:
274
+ json_out({"watches": rows})
275
+ return
276
+ if not rows:
277
+ console.print("No watches yet. Add one with `ghost-api watch add SITE OPERATION`.")
278
+ return
279
+ table = Table(title="Watches", title_justify="left")
280
+ for col in ("ID", "Watching", "Every", "Notify", "Last check", "Result"):
281
+ table.add_column(col, overflow="fold")
282
+ for w in rows:
283
+ table.add_row(
284
+ str(w["id"]), watch.describe(w), watch.every_label(w["every_minutes"]),
285
+ ", ".join(w["notify"]) or "-", (w["last_check_at"] or "never").replace("T", " "),
286
+ w["last_status"] or "-",
287
+ ) # fmt: skip
288
+ console.print(table)
289
+
290
+
291
+ @watch_app.command("run")
292
+ def watch_run(
293
+ ids: Annotated[list[int] | None, typer.Argument(help="Watch IDs (default: all).")] = None,
294
+ due: Annotated[bool, typer.Option("--due", help="Only watches that are due.")] = False,
295
+ db: Annotated[Path | None, typer.Option(help="SQLite database path.")] = None,
296
+ ) -> None:
297
+ """Check watches once now."""
298
+ with Store(_need_db(db)) as store:
299
+ targets = [w for w in store.watches() if not ids or w["id"] in ids]
300
+ if due:
301
+ targets = [w for w in targets if watch.is_due(w)]
302
+ if not targets:
303
+ console.print("Nothing to check." if due else "No such watch.")
304
+ return
305
+ for w in targets:
306
+ _print_check(watch.check(store, w))
307
+
308
+
309
+ @watch_app.command("changes")
310
+ def watch_changes(
311
+ watch_id: Annotated[int | None, typer.Argument(help="One watch (default: all).")] = None,
312
+ limit: Annotated[int, typer.Option(help="How many to show.")] = 30,
313
+ db: Annotated[Path | None, typer.Option(help="SQLite database path.")] = None,
314
+ ) -> None:
315
+ """Show recent changes found by your watches."""
316
+ with Store(_need_db(db)) as store:
317
+ changes = store.watch_changes(watch_id, limit)
318
+ if not changes:
319
+ console.print("No changes recorded yet.")
320
+ return
321
+ for c in changes:
322
+ line = watch.Change(c["kind"], c["key"], c["title"], c.get("fields") or {}).line()
323
+ console.print(f"[dim]{c['at'].replace('T', ' ')} #{c['watch_id']}[/] ", end="")
324
+ console.print(line, markup=False)
325
+
326
+
327
+ @watch_app.command("remove")
328
+ def watch_remove(
329
+ watch_id: Annotated[int, typer.Argument(help="The watch to remove.")],
330
+ db: Annotated[Path | None, typer.Option(help="SQLite database path.")] = None,
331
+ ) -> None:
332
+ """Stop watching (removes its history too)."""
333
+ with Store(_need_db(db)) as store:
334
+ removed = store.remove_watch(watch_id)
335
+ console.print(f"Removed watch #{watch_id}." if removed else f"No watch #{watch_id}.")
336
+ if not removed:
337
+ raise typer.Exit(code=1)
338
+
339
+
340
+ @watch_app.command("start")
341
+ def watch_start(
342
+ db: Annotated[Path | None, typer.Option(help="SQLite database path.")] = None,
343
+ ) -> None:
344
+ """Keep checking due watches until you press Ctrl+C."""
345
+ with Store(_need_db(db)) as store:
346
+ if not store.watches():
347
+ console.print(
348
+ "No watches yet, so there is nothing to check. Add one first, e.g.\n"
349
+ " ghost-api watch add quotes.toscrape.com get_quotes --every 6h"
350
+ )
351
+ raise typer.Exit(code=1)
352
+ console.print("Checking due watches every minute. Press Ctrl+C to stop.")
353
+ try:
354
+ watch.loop(store, _print_check)
355
+ except KeyboardInterrupt:
356
+ console.print("Stopped.")
357
+
358
+
359
+ def schedule_command(db: Path, every_minutes: int) -> list[str]:
360
+ """The Windows Task Scheduler command that runs due watches in the background."""
361
+ launcher = Path.home() / ".local" / "bin" / "ghost-api.cmd"
362
+ run = (
363
+ f'"{launcher}"' if launcher.exists() else f'"{Path(sys.executable)}" -m ghost_api'
364
+ ) + f' watch run --due --db "{db.resolve()}"'
365
+ minutes = max(5, min(every_minutes, 1439))
366
+ return ["schtasks", "/Create", "/F", "/SC", "MINUTE", "/MO", str(minutes),
367
+ "/TN", TASK_NAME, "/TR", run] # fmt: skip
368
+
369
+
370
+ @watch_app.command("schedule")
371
+ def watch_schedule(
372
+ every: Annotated[str, typer.Option(help="How often Windows wakes Ghost API up.")] = "30m",
373
+ remove: Annotated[bool, typer.Option("--remove", help="Remove the scheduled task.")] = False,
374
+ dry_run: Annotated[
375
+ bool, typer.Option("--dry-run", help="Show the command without running it.")
376
+ ] = False,
377
+ db: Annotated[Path | None, typer.Option(help="SQLite database path.")] = None,
378
+ ) -> None:
379
+ """Run due watches in the background (Windows Task Scheduler), even with no window open."""
380
+ import subprocess
381
+
382
+ path = _need_db(db)
383
+ if sys.platform != "win32":
384
+ where = path.resolve()
385
+ line = f'*/30 * * * * cd "{Path.cwd()}" && ghost-api watch run --due --db "{where}"'
386
+ console.print("Add this line with `crontab -e` (checks every 30 minutes):")
387
+ console.print(line, markup=False)
388
+ return
389
+ if remove:
390
+ command = ["schtasks", "/Delete", "/F", "/TN", TASK_NAME]
391
+ else:
392
+ try:
393
+ command = schedule_command(path, watch.parse_every(every))
394
+ except ValueError as exc:
395
+ console.print(str(exc))
396
+ raise typer.Exit(code=2) from exc
397
+ if dry_run:
398
+ console.print(subprocess.list2cmdline(command), markup=False)
399
+ return
400
+ done = subprocess.run(command, capture_output=True, text=True, check=False)
401
+ if done.returncode != 0:
402
+ console.print(f"Task Scheduler said: {(done.stderr or done.stdout).strip()}")
403
+ raise typer.Exit(code=1)
404
+ console.print(
405
+ "Removed the background task."
406
+ if remove
407
+ else f'Windows will check due watches every {every} (task "{TASK_NAME}"). '
408
+ "Remove it with `ghost-api watch schedule --remove`."
409
+ )
410
+
411
+
412
+ def _served(store_path: Path, sites: list[str] | None, quiet: Console) -> dict[str, Any]:
413
+ from ghost_api.serve import load_sites
414
+
415
+ with Store(store_path) as store:
416
+ names = [_site(store, s) for s in sites] if sites else None
417
+ try:
418
+ served = load_sites(store_path, names)
419
+ except OperationError as exc:
420
+ quiet.print(str(exc))
421
+ raise typer.Exit(code=1) from exc
422
+ if not served:
423
+ quiet.print("Nothing to serve: no stored site has endpoints Ghost API can call.")
424
+ raise typer.Exit(code=1)
425
+ return served
426
+
427
+
428
+ @app.command()
429
+ def serve(
430
+ sites: Annotated[
431
+ list[str] | None, typer.Argument(help="Sites to serve (default: all stored sites).")
432
+ ] = None,
433
+ port: Annotated[int, typer.Option(help="Port on this computer.")] = 8900,
434
+ db: Annotated[Path | None, typer.Option(help="SQLite database path.")] = None,
435
+ ) -> None:
436
+ """Serve sites as a clean local REST API (for Excel, scripts, any language)."""
437
+ import uvicorn
438
+
439
+ from ghost_api.serve.rest import create_rest_app
440
+ from ghost_api.site import to_url_id
441
+
442
+ served = _served(_need_db(db), sites, console)
443
+ hosts = {f"127.0.0.1:{port}", f"localhost:{port}"}
444
+ base = f"http://127.0.0.1:{port}"
445
+ console.print(f"[bold {ACCENT}]◆ Ghost API REST[/] {base} docs: {base}/docs")
446
+ for s in served.values():
447
+ for op in s.operations:
448
+ console.print(f" GET {base}/{to_url_id(s.site)}/{op.name}", markup=False)
449
+ console.print("[dim]Add &format=csv for spreadsheets. Local only. Ctrl+C to stop.[/]")
450
+ uvicorn.run(create_rest_app(served, hosts), host="127.0.0.1", port=port, log_level="warning")
451
+
452
+
453
+ @app.command()
454
+ def mcp(
455
+ sites: Annotated[
456
+ list[str] | None, typer.Argument(help="Sites to offer (default: all stored sites).")
457
+ ] = None,
458
+ db: Annotated[Path | None, typer.Option(help="SQLite database path.")] = None,
459
+ explore: Annotated[
460
+ bool,
461
+ typer.Option(
462
+ "--explore",
463
+ help="Ghost API as a toolset: the assistant explores new sites (no LLM key "
464
+ "needed) and uses explored ones (fetch, diff, generate).",
465
+ ),
466
+ ] = False,
467
+ out: Annotated[Path, typer.Option(help="Folder for generated clients.")] = Path("output"),
468
+ ) -> None:
469
+ """Offer Ghost API to AI assistants (MCP over stdio), e.g. for Claude Code:
470
+ claude mcp add ghost-api -- ghost-api mcp --explore (explore any site)
471
+ claude mcp add ghost -- ghost-api mcp turftown.in/chennai (one tool per operation)"""
472
+ import anyio
473
+
474
+ stderr = Console(stderr=True) # stdout carries the protocol
475
+ try:
476
+ from ghost_api.serve.mcp import run_stdio
477
+ except ImportError as exc:
478
+ stderr.print('The MCP server needs the mcp package: pip install "mcp>=1.2"')
479
+ raise typer.Exit(code=1) from exc
480
+ if explore:
481
+ from ghost_api.serve import explorer_mcp
482
+
483
+ stderr.print("Ghost API MCP server: explore sites and use explored ones")
484
+ anyio.run(explorer_mcp.run_stdio, db, out)
485
+ return
486
+ served = _served(_need_db(db), sites, stderr)
487
+ count = sum(len(s.operations) for s in served.values())
488
+ stderr.print(f"Ghost API MCP server: {count} tool(s) from {', '.join(served)}")
489
+ anyio.run(run_stdio, served)
490
+
491
+
492
+ app.add_typer(watch_app, name="watch")