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/site.py ADDED
@@ -0,0 +1,84 @@
1
+ """Which site a run belongs to: the key for its memory and its output folder.
2
+
3
+ Usually that is the host ("hn.algolia.com"). When the start URL begins with a path that
4
+ names a section of the site, that first segment is kept too ("www.google.com/maps"), so
5
+ one big host's separate apps (Maps, Search, Support...) don't share memory and files.
6
+ The host alone still decides what counts as the site's own API versus a third party.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from collections.abc import Iterable
13
+ from urllib.parse import urlsplit
14
+
15
+ from ghost_api.analysis.url_grouping import looks_like_id
16
+
17
+ # A section name: a short plain word, no dots (files), no encoded data.
18
+ _SECTION_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_-]{0,39}$")
19
+ # Locale prefixes (/en/, /en-us/, /pt_BR/) say which language, not which app.
20
+ _LOCALE_RE = re.compile(r"^[a-z]{2}([-_][A-Za-z]{2})?$")
21
+ _HOST_RE = r"[a-z0-9]([a-z0-9.-]*[a-z0-9])?"
22
+ SITE_RE = re.compile(rf"^{_HOST_RE}(/[A-Za-z][A-Za-z0-9_-]{{0,39}})?$")
23
+
24
+
25
+ def site_key(url: str) -> str:
26
+ """'https://www.google.com/maps/@12.9,77.6' -> 'www.google.com/maps';
27
+ 'https://hn.algolia.com/' -> 'hn.algolia.com'."""
28
+ parts = urlsplit(url)
29
+ host = (parts.hostname or "").lower()
30
+ segments = [s for s in parts.path.split("/") if s]
31
+ if segments and is_section(segments[0]):
32
+ return f"{host}/{segments[0]}"
33
+ return host
34
+
35
+
36
+ def is_section(segment: str) -> bool:
37
+ return bool(
38
+ _SECTION_RE.match(segment) and not _LOCALE_RE.match(segment) and not looks_like_id(segment)
39
+ )
40
+
41
+
42
+ def site_host(site: str) -> str:
43
+ """The host part: 'www.google.com/maps' -> 'www.google.com'."""
44
+ return site.split("/", 1)[0]
45
+
46
+
47
+ def is_site(name: str) -> bool:
48
+ """A safe site name (used in file paths and URLs): host, optionally /section."""
49
+ return bool(SITE_RE.match(name))
50
+
51
+
52
+ # In dashboard URLs a site is one path segment, so its "/" is written as "~".
53
+ def to_url_id(site: str) -> str:
54
+ return site.replace("/", "~")
55
+
56
+
57
+ def from_url_id(url_id: str) -> str:
58
+ return url_id.replace("~", "/", 1)
59
+
60
+
61
+ class SiteAmbiguous(ValueError):
62
+ def __init__(self, name: str, choices: list[str]) -> None:
63
+ self.choices = choices
64
+ super().__init__(f"{name} has several sites: {', '.join(choices)}. Name one of them.")
65
+
66
+
67
+ def resolve_site(name: str, known: Iterable[str]) -> str:
68
+ """What the user typed -> a stored site name.
69
+
70
+ Accepts a site ('www.google.com/maps'), a URL, or a bare domain. A bare domain with
71
+ exactly one stored site under it means that site; with several, SiteAmbiguous.
72
+ Unknown names are returned as typed (the caller reports "nothing stored")."""
73
+ name = name.strip().rstrip("/")
74
+ if "://" in name:
75
+ name = site_key(name)
76
+ sites = set(known)
77
+ if name in sites:
78
+ return name
79
+ under = sorted(s for s in sites if s.startswith(name + "/"))
80
+ if len(under) == 1:
81
+ return under[0]
82
+ if under:
83
+ raise SiteAmbiguous(name, under)
84
+ return name
ghost_api/specdiff.py ADDED
@@ -0,0 +1,122 @@
1
+ """`ghost-api verify-spec`: compare a published OpenAPI spec with what the site really does.
2
+
3
+ Documentation drifts from reality. Using the endpoints Ghost API observed and verified for
4
+ a site, this reports:
5
+
6
+ undocumented the site uses it, the spec doesn't mention it
7
+ not seen the spec documents it, the site never used it while explored
8
+ (it may be unused, removed, or just not reached)
9
+ field gaps for endpoints in both: reply fields the spec doesn't list, and spec
10
+ fields the real replies never contained
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import re
17
+ from dataclasses import asdict, dataclass, field
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ import yaml
22
+
23
+ from ghost_api.analysis.graphql import split_template
24
+ from ghost_api.analysis.models import Endpoint
25
+
26
+ METHODS = ("get", "post", "put", "patch", "delete", "head", "options")
27
+
28
+
29
+ class SpecError(Exception):
30
+ """The file is not an OpenAPI document Ghost API can read."""
31
+
32
+
33
+ @dataclass
34
+ class EndpointCheck:
35
+ endpoint: str # "GET /api/items/{}"
36
+ status: str # "documented", "undocumented", "not seen"
37
+ extra_fields: list[str] = field(default_factory=list) # in replies, not in the spec
38
+ missing_fields: list[str] = field(default_factory=list) # in the spec, never in replies
39
+
40
+
41
+ def load_spec(path: Path) -> dict[str, Any]:
42
+ try:
43
+ text = path.read_text(encoding="utf-8")
44
+ data = json.loads(text) if path.suffix == ".json" else yaml.safe_load(text)
45
+ except (OSError, ValueError, yaml.YAMLError) as exc:
46
+ raise SpecError(f"{path.name} can't be read: {exc}") from exc
47
+ if not isinstance(data, dict) or not isinstance(data.get("paths"), dict):
48
+ raise SpecError(f"{path.name} is not an OpenAPI document (no 'paths')")
49
+ return data
50
+
51
+
52
+ def _key(method: str, path: str) -> str:
53
+ return f"{method.upper()} {re.sub(r'{[^}]*}', '{}', path.rstrip('/') or '/')}"
54
+
55
+
56
+ def _resolve(spec: dict[str, Any], node: Any, depth: int = 0) -> dict[str, Any]:
57
+ if not isinstance(node, dict) or depth > 10:
58
+ return {}
59
+ ref = node.get("$ref")
60
+ if isinstance(ref, str) and ref.startswith("#/"):
61
+ target: Any = spec
62
+ for part in ref[2:].split("/"):
63
+ target = target.get(part, {}) if isinstance(target, dict) else {}
64
+ return _resolve(spec, target, depth + 1)
65
+ return node
66
+
67
+
68
+ def _spec_fields(spec: dict[str, Any], operation: dict[str, Any]) -> set[str] | None:
69
+ """Top-level fields of the operation's success reply, or None when not described."""
70
+ for code in ("200", "201", "2XX", "default"):
71
+ reply = _resolve(spec, (operation.get("responses") or {}).get(code))
72
+ content = reply.get("content") or {}
73
+ media = next((v for k, v in content.items() if "json" in k), None)
74
+ if media is None:
75
+ continue
76
+ schema = _resolve(spec, media.get("schema"))
77
+ if schema.get("type") == "array" or "items" in schema:
78
+ schema = _resolve(spec, schema.get("items"))
79
+ props = schema.get("properties")
80
+ if isinstance(props, dict):
81
+ return set(props)
82
+ return None
83
+
84
+
85
+ def _seen_fields(ep: Endpoint) -> set[str]:
86
+ schema = ep.response_schema or {}
87
+ if schema.get("type") == "array" or "items" in schema:
88
+ schema = schema.get("items") or {}
89
+ props = schema.get("properties")
90
+ return set(props) if isinstance(props, dict) else set()
91
+
92
+
93
+ def compare(spec: dict[str, Any], endpoints: list[Endpoint]) -> list[EndpointCheck]:
94
+ documented: dict[str, dict[str, Any]] = {}
95
+ for path, item in (spec.get("paths") or {}).items():
96
+ if not isinstance(item, dict):
97
+ continue
98
+ for method, operation in item.items():
99
+ if method in METHODS and isinstance(operation, dict):
100
+ documented[_key(method, str(path))] = operation
101
+ observed: dict[str, Endpoint] = {}
102
+ for ep in endpoints:
103
+ path, _ = split_template(ep.path_template)
104
+ observed.setdefault(_key(ep.method, path), ep)
105
+ checks = []
106
+ for key in sorted(observed.keys() | documented.keys()):
107
+ if key not in documented:
108
+ checks.append(EndpointCheck(key, "undocumented"))
109
+ elif key not in observed:
110
+ checks.append(EndpointCheck(key, "not seen"))
111
+ else:
112
+ check = EndpointCheck(key, "documented")
113
+ listed, seen = _spec_fields(spec, documented[key]), _seen_fields(observed[key])
114
+ if listed is not None and seen:
115
+ check.extra_fields = sorted(seen - listed)
116
+ check.missing_fields = sorted(listed - seen)
117
+ checks.append(check)
118
+ return checks
119
+
120
+
121
+ def to_json(checks: list[EndpointCheck]) -> list[dict[str, Any]]:
122
+ return [asdict(c) for c in checks]
File without changes
ghost_api/ui/keys.py ADDED
@@ -0,0 +1,134 @@
1
+ """Ctrl+C during a run inside the interactive session, read as a key, not a signal.
2
+
3
+ On Windows a Ctrl+C *signal* also reaches cmd.exe, which runs the `ghost-api.cmd`
4
+ launcher; when Ghost API later exits, cmd asks "Terminate batch job (Y/N)?". While a run
5
+ is going (and only then: never while you type), `ctrl_c_as_key()` switches the console so
6
+ Ctrl+C arrives as an ordinary key, read by a small thread. The first press asks the run
7
+ to stop at the next safe point (`stop_requested()`, checked between steps); a second
8
+ press interrupts at once; a third quits. The session itself keeps going.
9
+
10
+ That console switch also turns off Windows' line editing (Backspace, Enter), so anything
11
+ that reads a typed answer during a run must use `input_paused()`, which puts the normal
12
+ mode back while the user types.
13
+
14
+ On other systems, and when input isn't a console, these do nothing.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import _thread
20
+ import contextlib
21
+ import os
22
+ import sys
23
+ import threading
24
+ import time
25
+ from collections.abc import Callable, Iterator
26
+
27
+ ENABLE_PROCESSED_INPUT = 0x0001
28
+ STD_INPUT_HANDLE = -10
29
+
30
+
31
+ class _Watcher:
32
+ def __init__(self, set_mode: Callable[[bool], None]) -> None:
33
+ self.stop = threading.Event()
34
+ self.pause = threading.Event()
35
+ self.presses = 0
36
+ self.set_mode = set_mode # True: normal console mode (typing works)
37
+
38
+ def run(self) -> None:
39
+ if sys.platform != "win32":
40
+ return
41
+ import msvcrt
42
+
43
+ while not self.stop.is_set():
44
+ if self.pause.is_set() or not msvcrt.kbhit():
45
+ time.sleep(0.05)
46
+ continue
47
+ if self.pause.is_set(): # paused while waiting: leave the key to the reader
48
+ continue
49
+ if msvcrt.getwch() == "\x03":
50
+ self.presses += 1
51
+ pressed(self.presses)
52
+
53
+
54
+ _active: _Watcher | None = None
55
+ _stop = threading.Event()
56
+
57
+
58
+ def _say(message: str) -> None:
59
+ pass
60
+
61
+
62
+ # Set by the session: how to tell the user that a stop is on its way.
63
+ on_stop_requested: Callable[[str], None] = _say
64
+
65
+
66
+ def stop_requested() -> bool:
67
+ """Has the user asked the current run to stop? (Checked between steps.)"""
68
+ return _stop.is_set()
69
+
70
+
71
+ def pressed(presses: int) -> None:
72
+ """1st Ctrl+C: stop at the next safe point (a browser that is starting or loading a
73
+ page must not be interrupted half-way: it can hang). 2nd: interrupt now. 3rd: quit."""
74
+ if presses == 1:
75
+ _stop.set()
76
+ on_stop_requested("Stopping after the current step... (Ctrl+C again to stop at once)")
77
+ elif presses == 2:
78
+ _thread.interrupt_main()
79
+ else:
80
+ os._exit(130)
81
+
82
+
83
+ @contextlib.contextmanager
84
+ def ctrl_c_as_key() -> Iterator[None]:
85
+ """Use around running something (not around reading input)."""
86
+ global _active
87
+ if sys.platform != "win32" or not sys.stdin.isatty() or _active is not None:
88
+ yield
89
+ return
90
+ import ctypes
91
+
92
+ kernel = ctypes.windll.kernel32
93
+ handle = kernel.GetStdHandle(STD_INPUT_HANDLE)
94
+ mode = ctypes.c_uint32()
95
+ if not kernel.GetConsoleMode(handle, ctypes.byref(mode)):
96
+ yield
97
+ return
98
+ normal = mode.value
99
+
100
+ def set_mode(typing: bool) -> None:
101
+ kernel.SetConsoleMode(handle, normal if typing else normal & ~ENABLE_PROCESSED_INPUT)
102
+
103
+ watcher = _Watcher(set_mode)
104
+ _stop.clear()
105
+ set_mode(False)
106
+ thread = threading.Thread(target=watcher.run, daemon=True)
107
+ _active = watcher
108
+ thread.start()
109
+ try:
110
+ yield
111
+ finally:
112
+ watcher.stop.set()
113
+ thread.join(timeout=1)
114
+ set_mode(True)
115
+ _active = None
116
+ _stop.clear()
117
+
118
+
119
+ @contextlib.contextmanager
120
+ def input_paused() -> Iterator[None]:
121
+ """Let the user type an answer during a run: the watcher stops reading keys and the
122
+ console's normal mode (Backspace, Enter, Ctrl+C) comes back until the answer is in."""
123
+ watcher = _active
124
+ if watcher is None:
125
+ yield
126
+ return
127
+ watcher.pause.set()
128
+ time.sleep(0.06) # let the watcher finish any key it was reading
129
+ watcher.set_mode(True)
130
+ try:
131
+ yield
132
+ finally:
133
+ watcher.set_mode(False)
134
+ watcher.pause.clear()