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
@@ -0,0 +1,188 @@
1
+ """Find personal and sensitive data in API replies, and hide it before anything is kept.
2
+
3
+ Found on turftown.in: a public venue-details call returned owners' PAN and GST numbers,
4
+ bank UPI IDs and staff emails to anyone. Ghost API now spots such values when a reply is
5
+ captured, replaces them with a marker like "[hidden email: a***@g***.com]" (strings) or
6
+ 0 (numbers, so the inferred type stays the same), and records what it hid. The report
7
+ then shows which endpoints expose what, without ever storing the values themselves.
8
+
9
+ Detection uses the value's shape and the field's name together, to keep false alarms
10
+ low: a 10-digit number is only a phone number in a field called phone/mobile/contact.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import re
17
+ from dataclasses import asdict, dataclass
18
+ from typing import Any, Literal
19
+
20
+ Severity = Literal["high", "medium"]
21
+
22
+ _EMAIL_RE = re.compile(r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}$")
23
+ _UPI_RE = re.compile(r"^[A-Za-z0-9._-]{2,}@[A-Za-z][A-Za-z0-9]{1,}$") # name@okaxis (no dot)
24
+ _PAN_RE = re.compile(r"^[A-Z]{5}[0-9]{4}[A-Z]$")
25
+ _GSTIN_RE = re.compile(r"^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$")
26
+ _PHONE_RE = re.compile(r"^\+?[0-9][0-9 ()-]{7,16}[0-9]$")
27
+ _INTL_PHONE_RE = re.compile(r"^\+[1-9][0-9 ()-]{7,16}[0-9]$")
28
+ _AADHAAR_RE = re.compile(r"^[2-9][0-9]{3}[ -]?[0-9]{4}[ -]?[0-9]{4}$")
29
+ _DIGITS_RE = re.compile(r"^[0-9]{9,19}$")
30
+ _MARKER_RE = re.compile(r"^\[hidden ([a-z_]+): (.*)\]$")
31
+
32
+ _PHONE_NAMES = ("phone", "mobile", "contact", "whatsapp", "msisdn", "tel")
33
+ _ACCOUNT_NAMES = ("account_number", "accountnumber", "account_no", "acc_no", "bank_account")
34
+ _CARD_NAMES = ("card", "cc_number", "ccnumber")
35
+ _AADHAAR_NAMES = ("aadhaar", "aadhar", "uid_number")
36
+ _UPI_NAMES = ("upi", "vpa")
37
+ # Common UPI handle providers (the part after "@"), for values in fields not named upi/vpa.
38
+ _UPI_PROVIDERS = {
39
+ "okaxis", "okhdfcbank", "okicici", "oksbi", "ybl", "ibl", "axl", "paytm", "upi", "apl",
40
+ "yapl", "rapl", "airtel", "freecharge", "jio", "kotak", "hdfcbank", "icici", "sbi",
41
+ "axisbank", "pthdfc", "ptsbi", "ptyes", "ptaxis", "waaxis", "wahdfcbank", "wasbi",
42
+ "waicici", "abfspay", "ikwik", "idfcbank", "indus", "barodampay", "fbl", "federal",
43
+ } # fmt: skip
44
+ _DOB_NAMES = ("dob", "date_of_birth", "dateofbirth", "birth_date", "birthdate", "birthday")
45
+ # A person's name, when the field says so (names can't be told from their value alone).
46
+ _HOLDER_NAMES = (
47
+ "accountname", "account_name", "accountholder", "account_holder", "holder_name",
48
+ "holdername", "beneficiary", "owner_name", "ownername", "card_holder", "cardholder",
49
+ ) # fmt: skip
50
+
51
+ SEVERITY: dict[str, Severity] = {
52
+ "pan": "high", "gstin": "high", "aadhaar": "high", "bank_account": "high",
53
+ "card": "high", "upi": "high", "email": "medium", "phone": "medium",
54
+ "date_of_birth": "medium", "account_holder": "medium",
55
+ } # fmt: skip
56
+ LABEL = {
57
+ "pan": "PAN number", "gstin": "GST number", "aadhaar": "Aadhaar number",
58
+ "bank_account": "bank account number", "card": "card number", "upi": "UPI ID",
59
+ "email": "email address", "phone": "phone number", "date_of_birth": "date of birth",
60
+ "account_holder": "account holder's name",
61
+ } # fmt: skip
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class Finding:
66
+ path: str # field path, list positions as []: "data.rating[].email"
67
+ kind: str
68
+ severity: Severity
69
+ example: str # masked: "a***@g***.com"
70
+
71
+ def to_dict(self) -> dict[str, str]:
72
+ return asdict(self)
73
+
74
+
75
+ def classify(key: str, value: Any) -> str | None:
76
+ """The kind of personal data `value` (in field `key`) is, or None."""
77
+ if isinstance(value, bool) or value is None:
78
+ return None
79
+ name = key.lower()
80
+ text = str(value).strip()
81
+ if not text or _MARKER_RE.match(text):
82
+ return None
83
+ if isinstance(value, str):
84
+ if _EMAIL_RE.match(text):
85
+ return "email"
86
+ if _PAN_RE.match(text):
87
+ return "pan"
88
+ if _GSTIN_RE.match(text):
89
+ return "gstin"
90
+ if _UPI_RE.match(text) and (
91
+ any(n in name for n in _UPI_NAMES) or text.rpartition("@")[2].lower() in _UPI_PROVIDERS
92
+ ):
93
+ return "upi"
94
+ if _INTL_PHONE_RE.match(text) and _digit_count(text) >= 10:
95
+ return "phone"
96
+ if (
97
+ isinstance(value, str)
98
+ and any(n in name.replace("-", "_") for n in _HOLDER_NAMES)
99
+ and re.search(r"[A-Za-z]{2}", text)
100
+ ):
101
+ return "account_holder"
102
+ if any(n in name for n in _DOB_NAMES) and isinstance(value, str) and re.search(r"\d", text):
103
+ return "date_of_birth"
104
+ if any(n in name for n in _AADHAAR_NAMES) and _AADHAAR_RE.match(text):
105
+ return "aadhaar"
106
+ if any(n in name for n in _ACCOUNT_NAMES) and _DIGITS_RE.match(text):
107
+ return "bank_account"
108
+ if any(n in name for n in _CARD_NAMES) and _DIGITS_RE.match(text) and _luhn(text):
109
+ return "card"
110
+ if (
111
+ any(n in name for n in _PHONE_NAMES)
112
+ and _PHONE_RE.match(text)
113
+ and 10 <= _digit_count(text) <= 15
114
+ ):
115
+ return "phone"
116
+ return None
117
+
118
+
119
+ def mask(text: str, kind: str) -> str:
120
+ """Enough to recognise the kind, nothing to recover the value: 'a***@g***.com'."""
121
+ if kind in ("email", "upi") and "@" in text:
122
+ user, _, domain = text.partition("@")
123
+ head, dot, tld = domain.rpartition(".")
124
+ domain_masked = f"{head[:1]}***{dot}{tld}" if dot else f"{domain[:1]}***"
125
+ return f"{user[:1]}***@{domain_masked}"
126
+ keep = 2 if len(text) > 6 else 1
127
+ return text[:keep] + "*" * max(3, len(text) - keep - 1) + text[-1:]
128
+
129
+
130
+ def hide(data: Any, path: str = "") -> tuple[Any, list[Finding]]:
131
+ """A copy of `data` with personal values replaced, and what was replaced."""
132
+ findings: list[Finding] = []
133
+
134
+ def walk(value: Any, key: str, where: str) -> Any:
135
+ if isinstance(value, dict):
136
+ return {
137
+ k: walk(v, str(k), f"{where}.{k}" if where else str(k)) for k, v in value.items()
138
+ }
139
+ if isinstance(value, list):
140
+ return [walk(v, key, f"{where}[]") for v in value]
141
+ kind = classify(key, value)
142
+ if kind is None:
143
+ return value
144
+ example = mask(str(value).strip(), kind)
145
+ findings.append(Finding(where or key or "value", kind, SEVERITY[kind], example))
146
+ if isinstance(value, str):
147
+ return f"[hidden {kind}: {example}]"
148
+ return 0 # numbers stay numbers, so the inferred schema keeps its type
149
+
150
+ return walk(data, "", path), findings
151
+
152
+
153
+ def hide_body(body: str | None) -> tuple[str | None, list[dict[str, str]]]:
154
+ """For a JSON body (text): the body with personal values hidden, and what was hidden
155
+ (one entry per field path). Non-JSON bodies are returned as they are."""
156
+ if not body:
157
+ return body, []
158
+ try:
159
+ data = json.loads(body)
160
+ except ValueError:
161
+ return body, []
162
+ hidden, findings = hide(data)
163
+ if not findings:
164
+ return body, []
165
+ return json.dumps(hidden, ensure_ascii=False), summarize(findings)
166
+
167
+
168
+ def summarize(findings: list[Finding]) -> list[dict[str, str]]:
169
+ """One entry per (path, kind), with how many values were hidden."""
170
+ seen: dict[tuple[str, str], dict[str, Any]] = {}
171
+ for f in findings:
172
+ entry = seen.setdefault((f.path, f.kind), {**f.to_dict(), "count": 0})
173
+ entry["count"] += 1
174
+ return list(seen.values())
175
+
176
+
177
+ def _digit_count(text: str) -> int:
178
+ return sum(c.isdigit() for c in text)
179
+
180
+
181
+ def _luhn(digits: str) -> bool:
182
+ total = 0
183
+ for i, ch in enumerate(reversed(digits)):
184
+ n = int(ch)
185
+ if i % 2:
186
+ n = n * 2 - 9 if n > 4 else n * 2
187
+ total += n
188
+ return total % 10 == 0
@@ -0,0 +1,181 @@
1
+ """Record API traffic from a Playwright page (FR-13, FR-14).
2
+
3
+ The 'response' event handler only queues responses; bodies are read later in `flush()`,
4
+ which the browser driver calls after every action. Reading bodies outside the event
5
+ handler keeps the sync Playwright API safe from re-entrancy problems.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from collections import Counter
12
+ from collections.abc import Callable
13
+
14
+ from playwright.sync_api import Error as PlaywrightError
15
+ from playwright.sync_api import Page, Response
16
+
17
+ from ghost_api.capture import filters, personal
18
+ from ghost_api.capture.models import CapturedRequest, RawRequest
19
+
20
+ MAX_BODY_CHARS = 500_000
21
+ MAX_CAPTURE_BYTES = 5_000_000 # one reply bigger than this is not read (response-too-large)
22
+ RUN_CAPTURE_BUDGET = 200_000_000 # bodies read in one run; beyond it nothing more is read
23
+
24
+
25
+ class TrafficRecorder:
26
+ def __init__(
27
+ self,
28
+ target_host: str,
29
+ on_capture: Callable[[CapturedRequest], None] | None = None,
30
+ ) -> None:
31
+ self.target_host = target_host
32
+ self.on_capture = on_capture
33
+ self.captured: list[CapturedRequest] = []
34
+ # raw[i] is the unredacted original of captured[i] (memory only, for replaying).
35
+ self.raw: list[RawRequest | None] = []
36
+ self.dropped: Counter[str] = Counter()
37
+ self._pending: list[Response] = []
38
+ self.bytes_read = 0
39
+
40
+ def attach(self, page: Page) -> None:
41
+ page.on("response", self._on_response)
42
+
43
+ def _on_response(self, response: Response) -> None:
44
+ request = response.request
45
+ reason = filters.classify(request.resource_type, request.url, self.target_host)
46
+ if reason:
47
+ self.dropped[reason] += 1
48
+ return
49
+ if 300 <= response.status < 400:
50
+ self.dropped["redirect"] += 1
51
+ return
52
+ self._pending.append(response)
53
+
54
+ def flush(self) -> list[CapturedRequest]:
55
+ """Read bodies of queued responses, keep JSON ones, redact, and store them."""
56
+ new: list[CapturedRequest] = []
57
+ pending, self._pending = self._pending, []
58
+ for response in pending:
59
+ try:
60
+ built = self._build(response)
61
+ except Exception: # noqa: BLE001 - one odd response must not stop the run
62
+ self.dropped["unreadable"] += 1
63
+ continue
64
+ if built is None:
65
+ continue
66
+ new.append(self.add(*built))
67
+ return new
68
+
69
+ def add(self, item: CapturedRequest, raw: RawRequest | None = None) -> CapturedRequest:
70
+ """Record a request (from the browser, or a replay made by the prober)."""
71
+ self.captured.append(item)
72
+ self.raw.append(raw)
73
+ if self.on_capture:
74
+ self.on_capture(item)
75
+ return item
76
+
77
+ def _build(self, response: Response) -> tuple[CapturedRequest, RawRequest] | None:
78
+ request = response.request
79
+ try:
80
+ resp_headers = response.all_headers()
81
+ req_headers = request.all_headers()
82
+ declared = resp_headers.get("content-length", "")
83
+ if declared.isdigit() and int(declared) > MAX_CAPTURE_BYTES:
84
+ self.dropped["response-too-large"] += 1 # never read into memory
85
+ return None
86
+ if self.bytes_read > RUN_CAPTURE_BUDGET:
87
+ self.dropped["capture-budget-used"] += 1
88
+ return None
89
+ body_bytes = response.body()
90
+ post_bytes = request.post_data_buffer
91
+ except PlaywrightError:
92
+ # Body is gone (page navigated away, request aborted, ...).
93
+ self.dropped["no-body"] += 1
94
+ return None
95
+
96
+ self.bytes_read += len(body_bytes)
97
+ built = build_capture(
98
+ request.method, request.url, request.resource_type, response.status,
99
+ req_headers, post_bytes, resp_headers, body_bytes, self.target_host,
100
+ ) # fmt: skip
101
+ if isinstance(built, str):
102
+ self.dropped[built] += 1
103
+ return None
104
+ return built
105
+
106
+
107
+ def build_capture(
108
+ method: str,
109
+ url: str,
110
+ resource_type: str,
111
+ status: int,
112
+ req_headers: dict[str, str],
113
+ post_bytes: bytes | None,
114
+ resp_headers: dict[str, str],
115
+ body_bytes: bytes,
116
+ target_host: str,
117
+ ) -> tuple[CapturedRequest, RawRequest] | str:
118
+ """One request and reply, filtered and made safe to store: secrets redacted, personal
119
+ data hidden. Returns why it was dropped (a short reason) when it is not a JSON API call.
120
+ Used for the browser's traffic and for imported HAR files alike."""
121
+ req_headers = {k.lower(): v for k, v in req_headers.items()}
122
+ resp_headers = {k.lower(): v for k, v in resp_headers.items()}
123
+ if len(body_bytes) > MAX_CAPTURE_BYTES:
124
+ # The body is not decoded, parsed or stored, so a partial reply never becomes a schema.
125
+ return "response-too-large"
126
+ # Binary bodies (protobuf, images sent over fetch...) are not JSON APIs.
127
+ body = _utf8(body_bytes)
128
+ post_data = _utf8(post_bytes) if post_bytes else None
129
+ if body is None or (post_bytes and post_data is None):
130
+ return "binary"
131
+ resp_ctype = resp_headers.get("content-type")
132
+ if not filters.is_json_content_type(resp_ctype) and not _looks_like_json(body):
133
+ return "non-json"
134
+ body = filters.strip_xssi(body)
135
+ if body.strip() and not _looks_like_json(body):
136
+ # Says JSON but isn't (e.g. Google's length-prefixed batchexecute envelope).
137
+ return "invalid-json"
138
+
139
+ # Redact the full body first: truncated JSON can't be parsed for key-based redaction.
140
+ safe_body = filters.redact_body(body, resp_ctype) or ""
141
+ hidden_body, personal_data = personal.hide_body(safe_body)
142
+ safe_body = hidden_body or ""
143
+ truncated = len(safe_body) > MAX_BODY_CHARS
144
+ if truncated:
145
+ safe_body = safe_body[:MAX_BODY_CHARS]
146
+
147
+ req_ctype = req_headers.get("content-type")
148
+ raw = RawRequest(method, url, dict(req_headers), post_data)
149
+ captured = CapturedRequest(
150
+ method=method,
151
+ url=filters.redact_url(url),
152
+ status=status,
153
+ resource_type=resource_type,
154
+ req_headers=filters.redact_headers(req_headers),
155
+ req_body=filters.redact_body(post_data, req_ctype),
156
+ req_content_type=req_ctype,
157
+ resp_content_type=resp_ctype,
158
+ resp_body=safe_body,
159
+ truncated=truncated,
160
+ third_party=filters.is_third_party(url, target_host),
161
+ personal=personal_data,
162
+ )
163
+ return captured, raw
164
+
165
+
166
+ def _utf8(data: bytes) -> str | None:
167
+ try:
168
+ return data.decode("utf-8")
169
+ except UnicodeDecodeError:
170
+ return None
171
+
172
+
173
+ def _looks_like_json(body: str) -> bool:
174
+ stripped = body.lstrip()
175
+ if stripped[:1] not in ("{", "["):
176
+ return False
177
+ try:
178
+ json.loads(stripped)
179
+ except ValueError:
180
+ return False
181
+ return True
@@ -0,0 +1,44 @@
1
+ """The `ghost-api` command line.
2
+
3
+ _common.py the app, global options (--json, --plain, project defaults) and helpers
4
+ explore.py run, import, refresh, login, logout
5
+ client.py generate, verify, report, diff, mock
6
+ data.py fetch, watch, serve, mcp, ui
7
+ manage.py init, doctor, inspect, forget
8
+ """
9
+
10
+ # isort: off
11
+ # Importing the command modules registers their commands, in this order in --help.
12
+ from ghost_api.cli import explore # noqa: F401
13
+ from ghost_api.cli import client # noqa: F401
14
+ from ghost_api.cli import data # noqa: F401
15
+ from ghost_api.cli import manage # noqa: F401
16
+
17
+ # isort: on
18
+ from ghost_api.cli._common import (
19
+ GLOBAL_FLAGS,
20
+ _invoke,
21
+ _json_first,
22
+ _options,
23
+ _RunResult,
24
+ app,
25
+ console,
26
+ entry,
27
+ json_out,
28
+ safe_output,
29
+ )
30
+ from ghost_api.cli.data import schedule_command
31
+
32
+ __all__ = [
33
+ "GLOBAL_FLAGS",
34
+ "_RunResult",
35
+ "_invoke",
36
+ "_json_first",
37
+ "_options",
38
+ "app",
39
+ "console",
40
+ "entry",
41
+ "json_out",
42
+ "safe_output",
43
+ "schedule_command",
44
+ ]
@@ -0,0 +1,219 @@
1
+ """Shared parts of the command line: the app, global options and helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Annotated
9
+ from urllib.parse import urlsplit
10
+
11
+ import typer
12
+ from rich.console import Console
13
+
14
+ from ghost_api import __version__, exitcodes, workflows
15
+ from ghost_api.config import (
16
+ check_language,
17
+ default_db_path,
18
+ load_env,
19
+ state_dir,
20
+ )
21
+ from ghost_api.llm.providers import default_providers
22
+ from ghost_api.memory.store import Store, StoreError
23
+ from ghost_api.project import ProjectError, default_map, find_project
24
+ from ghost_api.site import SiteAmbiguous, resolve_site
25
+ from ghost_api.ui import keys
26
+ from ghost_api.ui.terminal import (
27
+ TerminalRenderer,
28
+ )
29
+
30
+ app = typer.Typer(
31
+ help="Ghost API: turn any website into a typed, tested API client.",
32
+ add_completion=True,
33
+ )
34
+ console = Console()
35
+ _options = {"plain": False, "json": False}
36
+
37
+
38
+ def _version(value: bool) -> None:
39
+ if value:
40
+ console.print(f"ghost-api {__version__}")
41
+ raise typer.Exit()
42
+
43
+
44
+ @app.callback(invoke_without_command=True)
45
+ def main(
46
+ ctx: typer.Context,
47
+ version: Annotated[
48
+ bool | None,
49
+ typer.Option(
50
+ "--version", callback=_version, is_eager=True, expose_value=False, help="Show version."
51
+ ),
52
+ ] = None,
53
+ plain: Annotated[
54
+ bool, typer.Option("--plain", help="Simple line-by-line output (for logs and CI).")
55
+ ] = False,
56
+ json_output: Annotated[
57
+ bool,
58
+ typer.Option(
59
+ "--json",
60
+ help="Print the result as JSON on stdout (progress goes to stderr). "
61
+ "Works anywhere on the command line.",
62
+ ),
63
+ ] = False,
64
+ ) -> None:
65
+ load_env()
66
+ for stream in (sys.stdout, sys.stderr):
67
+ safe_output(stream)
68
+ _options["plain"] = plain or json_output
69
+ _options["json"] = json_output
70
+ if json_output: # stdout is only for the JSON result; everything else goes to stderr
71
+ saved = console._file # usually None: Rich then follows sys.stdout as it changes
72
+ console.file = sys.stderr
73
+ ctx.call_on_close(lambda: setattr(console, "_file", saved))
74
+ try:
75
+ project = find_project()
76
+ except ProjectError as exc:
77
+ console.print(f"[red]{exc}[/]")
78
+ raise typer.Exit(code=exitcodes.USAGE) from exc
79
+ if project is not None:
80
+ ctx.default_map = default_map(project, ctx.command)
81
+ if ctx.invoked_subcommand is None:
82
+ if not sys.stdin.isatty():
83
+ console.print(ctx.get_help())
84
+ raise typer.Exit()
85
+ _interactive()
86
+
87
+
88
+ GLOBAL_FLAGS = ("--json", "--plain")
89
+
90
+
91
+ def _json_first(args: list[str]) -> list[str]:
92
+ """`--json` and `--plain` may be typed anywhere (`ghost-api inspect --json`); they are
93
+ global options, so they are moved in front of the command."""
94
+ flags = [f for f in GLOBAL_FLAGS if f in args]
95
+ return [*flags, *(a for a in args if a not in GLOBAL_FLAGS)] if flags else args
96
+
97
+
98
+ def json_out(data: object) -> None:
99
+ """The command's result, as JSON on stdout (progress and messages are on stderr)."""
100
+ sys.stdout.write(json.dumps(data, indent=2, ensure_ascii=False, default=str) + "\n")
101
+ sys.stdout.flush()
102
+
103
+
104
+ def entry() -> None:
105
+ """The `ghost-api` command: the app, with a plain message for a damaged database."""
106
+ try:
107
+ app(args=_json_first(sys.argv[1:]), prog_name="ghost-api")
108
+ except StoreError as exc:
109
+ console.print(f"[red]{exc}[/]")
110
+ raise SystemExit(1) from None
111
+
112
+
113
+ def safe_output(stream: object) -> None:
114
+ """Never crash on a character the output can't show (e.g. '◆' when output is
115
+ redirected to a file on Windows, which uses cp1252): print a '?' instead."""
116
+ if stream is None: # pythonw / no console: nothing to protect
117
+ return
118
+ encoding = (getattr(stream, "encoding", None) or "").lower().replace("-", "")
119
+ reconfigure = getattr(stream, "reconfigure", None)
120
+ if encoding != "utf8" and reconfigure is not None:
121
+ reconfigure(errors="replace")
122
+
123
+
124
+ def _invoke(args: list[str]) -> None:
125
+ """Run `ghost-api ARGS...` inside this process (the interactive session's commands)."""
126
+ command = typer.main.get_command(app)
127
+ try:
128
+ command.main(args=_json_first(args), prog_name="ghost-api", standalone_mode=False)
129
+ except typer.Abort:
130
+ console.print("[yellow]Stopped.[/]")
131
+ except typer.Exit:
132
+ pass
133
+ except StoreError as exc:
134
+ console.print(f"[red]{exc}[/]")
135
+ except typer.TyperException as exc: # usage errors: missing argument, unknown option...
136
+ format_message = getattr(exc, "format_message", None)
137
+ message = format_message() if callable(format_message) else str(exc)
138
+ console.print(f"[red]{message}[/] [dim](see /help)[/]")
139
+
140
+
141
+ def _interactive() -> None:
142
+ """`ghost-api` on its own: a session that stays open until /exit or Ctrl+C twice."""
143
+ from ghost_api.ui.repl import Repl, run_session
144
+
145
+ def ask(question: str) -> str:
146
+ with keys.input_paused():
147
+ return console.input(f"[bold]{question}[/]")
148
+
149
+ def has_llm() -> bool:
150
+ load_env() # a key added to .env while the session is open counts at once
151
+ return any(p.api_key for p in default_providers())
152
+
153
+ repl = Repl(console, _invoke, ask)
154
+ run_session(console, repl, has_llm, history_file=state_dir() / "history.txt")
155
+
156
+
157
+ class _RunResult(workflows.RunCollector):
158
+ """A run's outcome for `--json`, and its exit code."""
159
+
160
+ def finish(self, db: Path) -> None:
161
+ """Print the JSON (with --json) and exit with the code for the run's status."""
162
+ status = str(self.data["status"])
163
+ if _options["json"]:
164
+ json_out(self.data | {"database": str(db), "exit_code": _run_exit(status)})
165
+ if _run_exit(status):
166
+ raise typer.Exit(code=_run_exit(status))
167
+
168
+
169
+ def _run_exit(status: str) -> int:
170
+ return exitcodes.for_run_status(status)
171
+
172
+
173
+ def _renderer() -> TerminalRenderer:
174
+ return TerminalRenderer(console, plain=True if _options["plain"] else None)
175
+
176
+
177
+ def _normalize_url(url: str) -> str:
178
+ if "://" not in url:
179
+ url = "https://" + url
180
+ parts = urlsplit(url)
181
+ if parts.scheme not in ("http", "https") or not parts.hostname:
182
+ raise typer.BadParameter(f"Not a valid http(s) URL: {url}")
183
+ return url
184
+
185
+
186
+ def _check_lang(lang: str) -> str:
187
+ try:
188
+ return check_language(lang)
189
+ except ValueError as exc:
190
+ raise typer.BadParameter(str(exc)) from exc
191
+
192
+
193
+ def _not_allowed(url: str, reason: str = "disallowed by robots.txt") -> None:
194
+ if _options["json"]:
195
+ json_out({"status": "not-allowed", "url": url, "reason": reason,
196
+ "exit_code": exitcodes.NOT_ALLOWED}) # fmt: skip
197
+ elif not reason.startswith("robots.txt"): # robots.txt was already reported
198
+ console.print(f"[yellow]{reason}[/]")
199
+ raise typer.Exit(code=exitcodes.NOT_ALLOWED)
200
+
201
+
202
+ def _need_db(db: Path | None) -> Path:
203
+ path = db or default_db_path()
204
+ if not path.exists():
205
+ console.print(f"No database at {path}. Run `ghost-api run` first.")
206
+ raise typer.Exit(code=1)
207
+ return path
208
+
209
+
210
+ SITE_HELP = "Site explored before, e.g. quotes.toscrape.com or www.google.com/maps"
211
+
212
+
213
+ def _site(store: Store, name: str) -> str:
214
+ """The stored site for what was typed: a site, a URL, or just the domain."""
215
+ try:
216
+ return resolve_site(name, {r.domain for r in store.list_runs()})
217
+ except SiteAmbiguous as exc:
218
+ console.print(str(exc))
219
+ raise typer.Exit(code=1) from exc