ghost-api-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ghost_api/__init__.py +3 -0
- ghost_api/__main__.py +5 -0
- ghost_api/adopt.py +173 -0
- ghost_api/agent/__init__.py +0 -0
- ghost_api/agent/orchestrator.py +413 -0
- ghost_api/agent/prompts.py +149 -0
- ghost_api/agent/tools.py +84 -0
- ghost_api/analysis/__init__.py +0 -0
- ghost_api/analysis/analyzer.py +130 -0
- ghost_api/analysis/detectors.py +188 -0
- ghost_api/analysis/exposure.py +80 -0
- ghost_api/analysis/graphql.py +67 -0
- ghost_api/analysis/models.py +64 -0
- ghost_api/analysis/schema.py +109 -0
- ghost_api/analysis/url_grouping.py +217 -0
- ghost_api/api.py +264 -0
- ghost_api/audit.py +78 -0
- ghost_api/browser/__init__.py +0 -0
- ghost_api/browser/auth_state.py +172 -0
- ghost_api/browser/driver.py +273 -0
- ghost_api/browser/egress.py +183 -0
- ghost_api/browser/gate.py +127 -0
- ghost_api/browser/install.py +28 -0
- ghost_api/browser/snapshot.py +221 -0
- ghost_api/capture/__init__.py +0 -0
- ghost_api/capture/filters.py +255 -0
- ghost_api/capture/har.py +148 -0
- ghost_api/capture/models.py +41 -0
- ghost_api/capture/personal.py +188 -0
- ghost_api/capture/recorder.py +181 -0
- ghost_api/cli/__init__.py +44 -0
- ghost_api/cli/_common.py +219 -0
- ghost_api/cli/client.py +487 -0
- ghost_api/cli/data.py +492 -0
- ghost_api/cli/explore.py +379 -0
- ghost_api/cli/manage.py +437 -0
- ghost_api/config.py +132 -0
- ghost_api/drift.py +344 -0
- ghost_api/drive.py +211 -0
- ghost_api/errors.py +106 -0
- ghost_api/events.py +213 -0
- ghost_api/exitcodes.py +21 -0
- ghost_api/export/__init__.py +71 -0
- ghost_api/export/confidence.py +45 -0
- ghost_api/export/html.py +41 -0
- ghost_api/export/openapi.py +201 -0
- ghost_api/export/report.py +227 -0
- ghost_api/export/site_data.py +196 -0
- ghost_api/export/tabular.py +88 -0
- ghost_api/export/viewer/report.html +286 -0
- ghost_api/generator/__init__.py +221 -0
- ghost_api/generator/cassettes.py +80 -0
- ghost_api/generator/models_gen.py +144 -0
- ghost_api/generator/naming.py +174 -0
- ghost_api/generator/package.py +197 -0
- ghost_api/generator/python_gen.py +172 -0
- ghost_api/generator/spec.py +486 -0
- ghost_api/generator/templates/client.py.j2 +518 -0
- ghost_api/generator/templates/client.test.ts.j2 +106 -0
- ghost_api/generator/templates/client.ts.j2 +339 -0
- ghost_api/generator/templates/models.py.j2 +45 -0
- ghost_api/generator/templates/package_readme.md.j2 +111 -0
- ghost_api/generator/templates/pyproject.toml.j2 +22 -0
- ghost_api/generator/templates/test_client.py.j2 +105 -0
- ghost_api/generator/ts_gen.py +232 -0
- ghost_api/integrations/__init__.py +0 -0
- ghost_api/integrations/claude_code/commands/ghost-data.md +10 -0
- ghost_api/integrations/claude_code/commands/ghost-diff.md +10 -0
- ghost_api/integrations/claude_code/commands/ghost-explore.md +10 -0
- ghost_api/integrations/claude_code/skills/ghost-api/SKILL.md +46 -0
- ghost_api/integrations/setup.py +84 -0
- ghost_api/llm/__init__.py +0 -0
- ghost_api/llm/doctor.py +60 -0
- ghost_api/llm/gateway.py +476 -0
- ghost_api/llm/providers.py +124 -0
- ghost_api/memory/__init__.py +0 -0
- ghost_api/memory/knowledge.py +157 -0
- ghost_api/memory/store.py +883 -0
- ghost_api/net.py +235 -0
- ghost_api/netguard.py +203 -0
- ghost_api/pipeline.py +745 -0
- ghost_api/probe/__init__.py +0 -0
- ghost_api/probe/prober.py +311 -0
- ghost_api/probe/replay.py +313 -0
- ghost_api/project.py +186 -0
- ghost_api/py.typed +0 -0
- ghost_api/runtime.py +397 -0
- ghost_api/serve/__init__.py +101 -0
- ghost_api/serve/explorer_mcp.py +305 -0
- ghost_api/serve/mcp.py +121 -0
- ghost_api/serve/mock.py +130 -0
- ghost_api/serve/rest.py +132 -0
- ghost_api/site.py +84 -0
- ghost_api/specdiff.py +122 -0
- ghost_api/ui/__init__.py +0 -0
- ghost_api/ui/keys.py +134 -0
- ghost_api/ui/repl.py +453 -0
- ghost_api/ui/terminal.py +573 -0
- ghost_api/verify/__init__.py +0 -0
- ghost_api/verify/classify.py +84 -0
- ghost_api/verify/repair.py +40 -0
- ghost_api/verify/runner.py +110 -0
- ghost_api/verify/verifier.py +291 -0
- ghost_api/watch.py +313 -0
- ghost_api/web/__init__.py +1 -0
- ghost_api/web/server.py +690 -0
- ghost_api/web/static/assets/Endpoints-zPnjlcM8.js +46 -0
- ghost_api/web/static/assets/Run-D85zWHey.js +91 -0
- ghost_api/web/static/assets/Settings-D0aWHFAt.js +11 -0
- ghost_api/web/static/assets/Site-a88shEmP.js +71 -0
- ghost_api/web/static/assets/Watches-CqZWIa9G.js +16 -0
- ghost_api/web/static/assets/circle-x-BcTE_1tr.js +11 -0
- ghost_api/web/static/assets/index-Ds2e5B9f.js +141 -0
- ghost_api/web/static/assets/index-xQpyIBbG.css +1 -0
- ghost_api/web/static/assets/minus-C2xqP1eQ.js +6 -0
- ghost_api/web/static/assets/refresh-cw-CxdR_8Oi.js +6 -0
- ghost_api/web/static/favicon.svg +1 -0
- ghost_api/web/static/index.html +26 -0
- ghost_api/workflows.py +203 -0
- ghost_api_cli-0.2.0.dist-info/METADATA +1019 -0
- ghost_api_cli-0.2.0.dist-info/RECORD +125 -0
- ghost_api_cli-0.2.0.dist-info/WHEEL +5 -0
- ghost_api_cli-0.2.0.dist-info/entry_points.txt +2 -0
- ghost_api_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- ghost_api_cli-0.2.0.dist-info/top_level.txt +1 -0
ghost_api/cli/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")
|