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,197 @@
1
+ """Turn a generated client into a package you can install, publish or vendor.
2
+
3
+ Next to the loose files (which you can copy into a project as they are), each generate
4
+ writes:
5
+
6
+ package/python/ pyproject.toml, README.md, src/<module>/ (client, models, py.typed)
7
+ package/typescript/ package.json, tsconfig.json, README.md, src/index.ts
8
+
9
+ `pip install output/<site>/package/python` and `npm install output/<site>/package/typescript`
10
+ then work, and `from <module> import <Client>` is the same import as with the loose files.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import ast
16
+ import json
17
+ import shlex
18
+ from pathlib import Path
19
+ from typing import Any
20
+ from urllib.parse import urlencode
21
+
22
+ from jinja2 import Environment, FileSystemLoader, StrictUndefined
23
+
24
+ from ghost_api import __version__
25
+ from ghost_api.generator.spec import ClientSpec, MethodSpec
26
+ from ghost_api.generator.ts_gen import camel
27
+
28
+ TEMPLATES = Path(__file__).parent / "templates"
29
+
30
+
31
+ def dist_name(module: str) -> str:
32
+ return module.replace("_", "-")
33
+
34
+
35
+ def example_value(literal: str | None) -> Any:
36
+ """'"dune"' -> 'dune', '12' -> 12: a generated test's example as a plain value."""
37
+ if literal is None:
38
+ return None
39
+ try:
40
+ return ast.literal_eval(literal)
41
+ except (ValueError, SyntaxError):
42
+ return literal
43
+
44
+
45
+ def curl_example(spec: ClientSpec, method: MethodSpec) -> str:
46
+ """A copy-paste curl command for a method, using values seen while exploring."""
47
+ base = f"https://{method.host}" if method.host else spec.base_url
48
+ path = method.path
49
+ for p in method.by_location("path"):
50
+ path = path.replace("{" + p.name + "}", str(example_value(p.example) or f"<{p.name}>"))
51
+ query = {
52
+ p.wire: example_value(p.example)
53
+ for p in method.by_location("query")
54
+ if p.required and p.example is not None
55
+ }
56
+ creds = {c.arg: c for c in spec.credentials if c.arg in method.credentials}
57
+ for cred in creds.values():
58
+ if cred.location == "query":
59
+ query[cred.wire] = f"${cred.env}"
60
+ url = base + path + (f"?{urlencode(query, safe='$')}" if query else "")
61
+ parts = ["curl"]
62
+ if method.http_method != "GET":
63
+ parts += ["-X", method.http_method]
64
+ parts.append(shlex.quote(url) if "$" not in url else f'"{url}"')
65
+ for name, value in method.headers.items():
66
+ parts += ["-H", shlex.quote(f"{name}: {value}")]
67
+ for cred in creds.values():
68
+ if cred.location == "header":
69
+ parts += ["-H", f'"{cred.wire}: ${cred.env}"']
70
+ body = {
71
+ p.wire: example_value(p.example)
72
+ for p in method.by_location("body")
73
+ if p.example is not None
74
+ }
75
+ if method.by_location("body"):
76
+ parts += ["-H", shlex.quote("Content-Type: application/json")]
77
+ parts += ["-d", shlex.quote(json.dumps(body))]
78
+ return " ".join(parts)
79
+
80
+
81
+ def _env() -> Environment:
82
+ env = Environment(
83
+ loader=FileSystemLoader(TEMPLATES),
84
+ trim_blocks=True,
85
+ lstrip_blocks=True,
86
+ keep_trailing_newline=True,
87
+ undefined=StrictUndefined,
88
+ autoescape=False, # Markdown and config files
89
+ )
90
+ env.filters.update(camel=camel, json=json.dumps)
91
+ return env
92
+
93
+
94
+ def readme(spec: ClientSpec, module: str, lang: str) -> str:
95
+ methods = [
96
+ {
97
+ "method": m,
98
+ "curl": curl_example(spec, m),
99
+ "py_args": ", ".join(f"{p.name}={p.example}" for p in m.required if p.example),
100
+ "ts_args": ", ".join(
101
+ json.dumps(example_value(p.example)) for p in m.required if p.example
102
+ ),
103
+ "ts_name": camel(m.name),
104
+ "ts_iter": "iter" + camel(m.name)[:1].upper() + camel(m.name)[1:],
105
+ }
106
+ for m in spec.methods
107
+ ]
108
+ return (
109
+ _env()
110
+ .get_template("package_readme.md.j2")
111
+ .render(
112
+ spec=spec,
113
+ module=module,
114
+ dist=dist_name(module),
115
+ lang=lang,
116
+ methods=methods,
117
+ version=__version__,
118
+ client_version=spec.version,
119
+ )
120
+ )
121
+
122
+
123
+ def python_package(spec: ClientSpec, module: str, files: dict[str, str], out_dir: Path) -> Path:
124
+ """`files`: the rendered loose files ({module}.py, models.py, test_{module}.py)."""
125
+ root = out_dir / "package" / "python"
126
+ src = root / "src" / module
127
+ src.mkdir(parents=True, exist_ok=True)
128
+ (root / "tests").mkdir(exist_ok=True)
129
+ clients = [spec.class_name, spec.async_class, spec.error_class, *spec.error_classes.values()]
130
+ doc = f"{spec.class_name}: a typed client for {spec.domain} (generated by Ghost API)."
131
+ lines = [f'"""{doc}"""', ""]
132
+ lines += ["from .client import (", *(f" {name}," for name in clients), ")"]
133
+ if spec.model_names:
134
+ lines += ["from .models import (", *(f" {name}," for name in spec.model_names), ")"]
135
+ lines += ["", f"__all__ = {[*clients, *spec.model_names]!r}", ""]
136
+ init = "\n".join(lines)
137
+ contents = {
138
+ src / "__init__.py": init,
139
+ src / "client.py": files[f"{module}.py"],
140
+ src / "models.py": files["models.py"],
141
+ src / "py.typed": "",
142
+ root / "tests" / f"test_{module}.py": files[f"test_{module}.py"],
143
+ root / "pyproject.toml": _env()
144
+ .get_template("pyproject.toml.j2")
145
+ .render(spec=spec, module=module, dist=dist_name(module), version=spec.version),
146
+ root / "README.md": readme(spec, module, "python"),
147
+ }
148
+ if spec.changelog:
149
+ contents[root / "CHANGELOG.md"] = spec.changelog
150
+ for path, text in contents.items():
151
+ path.write_text(text, encoding="utf-8", newline="\n")
152
+ return root
153
+
154
+
155
+ def typescript_package(spec: ClientSpec, module: str, client_source: str, out_dir: Path) -> Path:
156
+ root = out_dir / "package" / "typescript"
157
+ (root / "src").mkdir(parents=True, exist_ok=True)
158
+ package_json = {
159
+ "name": dist_name(module),
160
+ "version": spec.version,
161
+ "description": f"Unofficial typed client for {spec.domain} (generated by Ghost API)",
162
+ "type": "module",
163
+ "main": "./dist/index.js",
164
+ "types": "./dist/index.d.ts",
165
+ "exports": {".": {"types": "./dist/index.d.ts", "default": "./dist/index.js"}},
166
+ "files": ["dist", "README.md"],
167
+ "engines": {"node": ">=18"},
168
+ "scripts": {"build": "tsc -p .", "prepare": "tsc -p ."},
169
+ "devDependencies": {"typescript": "^5.4.0"},
170
+ "keywords": [spec.domain, "api-client", "ghost-api"],
171
+ "license": "UNLICENSED",
172
+ }
173
+ tsconfig = {
174
+ "compilerOptions": {
175
+ "target": "es2022",
176
+ "module": "es2022",
177
+ "moduleResolution": "bundler",
178
+ "lib": ["es2022", "dom"],
179
+ "strict": True,
180
+ "declaration": True,
181
+ "outDir": "dist",
182
+ "rootDir": "src",
183
+ "skipLibCheck": True,
184
+ },
185
+ "include": ["src"],
186
+ }
187
+ contents = {
188
+ root / "src" / "index.ts": client_source,
189
+ root / "package.json": json.dumps(package_json, indent=2) + "\n",
190
+ root / "tsconfig.json": json.dumps(tsconfig, indent=2) + "\n",
191
+ root / "README.md": readme(spec, module, "typescript"),
192
+ }
193
+ if spec.changelog:
194
+ contents[root / "CHANGELOG.md"] = spec.changelog
195
+ for path, text in contents.items():
196
+ path.write_text(text, encoding="utf-8", newline="\n")
197
+ return root
@@ -0,0 +1,172 @@
1
+ """Render a ClientSpec into Python files, then check them (Stage 6).
2
+
3
+ Output, in output/<domain>/:
4
+ <name>_client.py the client class
5
+ models.py pydantic models
6
+ test_<name>.py live tests (run in Stage 7)
7
+
8
+ Checks before anything is written:
9
+ - every file compiles
10
+ - no secret-looking value and no [REDACTED] placeholder is present (NFR-16)
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ import shutil
17
+ import subprocess
18
+ import sys
19
+ from dataclasses import dataclass
20
+ from datetime import UTC, datetime
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from jinja2 import Environment, FileSystemLoader, StrictUndefined
25
+
26
+ from ghost_api import __version__
27
+ from ghost_api.capture.filters import REDACTED, redact_text
28
+ from ghost_api.config import USER_AGENT_SUFFIX
29
+ from ghost_api.generator.naming import snake
30
+ from ghost_api.generator.spec import ClientSpec, CredentialSpec, MethodSpec
31
+
32
+ TEMPLATES = Path(__file__).parent / "templates"
33
+
34
+
35
+ class GenerationError(Exception):
36
+ pass
37
+
38
+
39
+ @dataclass
40
+ class GeneratedClient:
41
+ directory: Path
42
+ module: str # import name of the client module
43
+ client_path: Path
44
+ models_path: Path
45
+ tests_path: Path
46
+ formatted: bool
47
+
48
+
49
+ def module_name(spec: ClientSpec) -> str:
50
+ return snake(spec.class_name.removesuffix("Client")) + "_client"
51
+
52
+
53
+ def render(spec: ClientSpec, packaged: bool = False) -> dict[str, str]:
54
+ """Render all files to strings: {filename: source}. `packaged`: for package/python
55
+ (the client imports its models only as `.models`)."""
56
+ env = Environment(
57
+ loader=FileSystemLoader(TEMPLATES),
58
+ trim_blocks=True,
59
+ lstrip_blocks=True,
60
+ keep_trailing_newline=True,
61
+ undefined=StrictUndefined,
62
+ autoescape=False, # generating Python, not HTML
63
+ )
64
+ env.filters["pyrepr"] = repr
65
+ env.filters["doc"] = _doc
66
+ env.filters["path_expr"] = _path_expr
67
+ env.filters["creds"] = _creds
68
+ env.filters["env_map"] = lambda creds: {c.arg: c.env for c in creds}
69
+
70
+ module = module_name(spec)
71
+ model_types = " ".join(f.type for m in spec.models for f in m.fields)
72
+ context: dict[str, Any] = {
73
+ "spec": spec,
74
+ "module": module,
75
+ "version": __version__,
76
+ "generated_on": datetime.now(UTC).strftime("%Y-%m-%d"),
77
+ "user_agent": f"{module}/{__version__} (generated by {USER_AGENT_SUFFIX})",
78
+ "uses_any": bool(re.search(r"\bAny\b", model_types)),
79
+ "uses_field": any(f.alias for m in spec.models for f in m.fields),
80
+ "packaged": packaged,
81
+ }
82
+ return {
83
+ f"{module}.py": env.get_template("client.py.j2").render(context),
84
+ "models.py": env.get_template("models.py.j2").render(context),
85
+ f"test_{module}.py": env.get_template("test_client.py.j2").render(context),
86
+ }
87
+
88
+
89
+ def check(files: dict[str, str]) -> None:
90
+ for name, source in files.items():
91
+ try:
92
+ compile(source, name, "exec")
93
+ except SyntaxError as exc:
94
+ raise GenerationError(f"{name} does not compile: {exc}") from exc
95
+ if REDACTED in source:
96
+ raise GenerationError(f"{name} contains a redacted placeholder")
97
+ if redact_text(source) != source:
98
+ raise GenerationError(f"{name} contains a value that looks like a secret")
99
+
100
+
101
+ def write(spec: ClientSpec, out_dir: Path) -> GeneratedClient:
102
+ files = render(spec)
103
+ check(files)
104
+ out_dir.mkdir(parents=True, exist_ok=True)
105
+ for name, source in files.items():
106
+ (out_dir / name).write_text(source, encoding="utf-8", newline="\n")
107
+ formatted = _format(out_dir, list(files))
108
+ if formatted: # formatting must not break anything
109
+ check({n: (out_dir / n).read_text(encoding="utf-8") for n in files})
110
+ module = module_name(spec)
111
+ from ghost_api.generator.package import python_package # ts_gen imports this module
112
+
113
+ packaged = render(spec, packaged=True)
114
+ check(packaged)
115
+ root = python_package(spec, module, packaged, out_dir)
116
+ _format(root / "src" / module, ["client.py", "models.py"])
117
+ return GeneratedClient(
118
+ directory=out_dir,
119
+ module=module,
120
+ client_path=out_dir / f"{module}.py",
121
+ models_path=out_dir / "models.py",
122
+ tests_path=out_dir / f"test_{module}.py",
123
+ formatted=formatted,
124
+ )
125
+
126
+
127
+ def _format(directory: Path, names: list[str]) -> bool:
128
+ """Format with ruff if it's installed; generated code is readable without it too."""
129
+ cmd = [sys.executable, "-m", "ruff"] if _has_module("ruff") else None
130
+ if cmd is None and shutil.which("ruff"):
131
+ cmd = ["ruff"]
132
+ if cmd is None:
133
+ return False
134
+ try:
135
+ subprocess.run(
136
+ [*cmd, "format", "--quiet", "--line-length", "100", *names],
137
+ cwd=directory,
138
+ check=True,
139
+ capture_output=True,
140
+ timeout=60,
141
+ )
142
+ except (subprocess.SubprocessError, OSError):
143
+ return False
144
+ return True
145
+
146
+
147
+ def _has_module(name: str) -> bool:
148
+ import importlib.util
149
+
150
+ return importlib.util.find_spec(name) is not None
151
+
152
+
153
+ # --- template filters ------------------------------------------------------------------------
154
+
155
+
156
+ def _doc(text: str) -> str:
157
+ """Safe inside a triple-quoted docstring."""
158
+ return text.replace("\\", "/").replace('"""', "'''").strip()
159
+
160
+
161
+ def _path_expr(method: MethodSpec) -> str:
162
+ """'/api/books/{id}' -> f"/api/books/{quote(str(id), safe='')}"."""
163
+ if "{" not in method.path:
164
+ return repr(method.path)
165
+ escaped = method.path.replace("'", "\\'")
166
+ expr = re.sub(r"\{(\w+)\}", lambda m: "{quote(str(" + m.group(1) + "), safe='')}", escaped)
167
+ return 'f"' + expr.replace('"', '\\"') + '"'
168
+
169
+
170
+ def _creds(method: MethodSpec, spec: ClientSpec, location: str) -> list[CredentialSpec]:
171
+ wanted = set(method.credentials)
172
+ return [c for c in spec.credentials if c.arg in wanted and location in ("any", c.location)]