ghosttrap-cli 0.3.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.
|
File without changes
|
ghosttrap_cli/cli.py
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""ghosttrap CLI — watch for errors streaming from ghosttrap.io."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
import websockets
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
GHOSTTRAP_SERVER = "wss://ghosttrap.io/stream/"
|
|
14
|
+
CONFIG_DIR = os.path.expanduser("~/.ghosttrap")
|
|
15
|
+
CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json")
|
|
16
|
+
SKILL_DIR = os.path.expanduser("~/.claude/skills/ghosttrap")
|
|
17
|
+
SKILL_FILE = os.path.join(SKILL_DIR, "SKILL.md")
|
|
18
|
+
|
|
19
|
+
SKILL_CONTENT = """\
|
|
20
|
+
---
|
|
21
|
+
name: ghosttrap
|
|
22
|
+
description: Production error monitoring via ghosttrap.io. Trigger when starting work on a configured project, when the user mentions production errors, or when you see ghosttrap references in code.
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
# Ghosttrap
|
|
26
|
+
|
|
27
|
+
Read `~/.ghosttrap/config.json` for state. It contains:
|
|
28
|
+
- `repos`: map of `"owner/repo"` to `{"token": "t_xxx", "sdk_installed": bool, "sdk_version": str, "init_file": str}`
|
|
29
|
+
- `cursor`: last seen error ID
|
|
30
|
+
|
|
31
|
+
## On session start
|
|
32
|
+
|
|
33
|
+
1. Detect the current repo from `git config --get remote.origin.url`.
|
|
34
|
+
2. Look it up in the config. If the repo isn't there, tell the user to run `ghosttrap setup`.
|
|
35
|
+
3. If `sdk_installed` is false or missing: install the SDK (`pip install git+https://github.com/arowley-predictive-power/ghosttrap-sdk.git`), wire `ghosttrap.init("<token>")` into the app startup. For Django projects, also add `"ghosttrap.django.GhostTrapApp"` to INSTALLED_APPS (re-attaches logging handler after Django's dictConfig) and `"ghosttrap.django.GhostTrapMiddleware"` to MIDDLEWARE (catches unhandled view exceptions). The SDK auto-hooks into Celery task_failure if Celery is installed, and attaches a logging handler for logger.exception() calls. Use whatever pattern the project already uses for configuration (env vars, settings files, hardcoded — match the existing style). Then update the config: set `sdk_installed: true`, `sdk_version`, `init_file` to record what you did.
|
|
36
|
+
4. Run `ghosttrap peek` with `run_in_background: true`.
|
|
37
|
+
|
|
38
|
+
## When peek returns
|
|
39
|
+
|
|
40
|
+
The output is JSON with `error.repo`, `error.type`, `error.message`, `error.traceback` (list of strings), `error.frames` (list of `{file, line, function, code}`). Open the file from the last frame, diagnose, fix. Then run `ghosttrap peek` in the background again.
|
|
41
|
+
|
|
42
|
+
## Rules
|
|
43
|
+
|
|
44
|
+
- Always `run_in_background: true` for peek — it blocks.
|
|
45
|
+
- Don't run multiple peeks at once.
|
|
46
|
+
- If peek exits without output (connection lost), restart it.
|
|
47
|
+
- After installing/updating the SDK, write the state back to config.json.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _load_config():
|
|
52
|
+
if os.path.exists(CONFIG_FILE):
|
|
53
|
+
with open(CONFIG_FILE) as f:
|
|
54
|
+
return json.load(f)
|
|
55
|
+
return {"repos": {}}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _save_config(config):
|
|
59
|
+
os.makedirs(CONFIG_DIR, exist_ok=True)
|
|
60
|
+
with open(CONFIG_FILE, "w") as f:
|
|
61
|
+
json.dump(config, f, indent=2)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _is_known_repo(config, owner, name):
|
|
65
|
+
return f"{owner}/{name}" in config.get("repos", {})
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _save_repos(config, repos):
|
|
69
|
+
if "repos" not in config:
|
|
70
|
+
config["repos"] = {}
|
|
71
|
+
for r in repos:
|
|
72
|
+
key = f"{r['owner']}/{r['name']}"
|
|
73
|
+
config["repos"][key] = {"token": r["token"]}
|
|
74
|
+
_save_config(config)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _detect_repo_from_cwd():
|
|
78
|
+
try:
|
|
79
|
+
result = subprocess.run(
|
|
80
|
+
["git", "config", "--get", "remote.origin.url"],
|
|
81
|
+
capture_output=True, text=True, timeout=5,
|
|
82
|
+
)
|
|
83
|
+
url = result.stdout.strip()
|
|
84
|
+
if not url:
|
|
85
|
+
return None
|
|
86
|
+
for prefix in ["git@github.com:", "https://github.com/"]:
|
|
87
|
+
if url.startswith(prefix):
|
|
88
|
+
path = url[len(prefix):]
|
|
89
|
+
if path.endswith(".git"):
|
|
90
|
+
path = path[:-4]
|
|
91
|
+
return path
|
|
92
|
+
if ":" in url and not url.startswith("http"):
|
|
93
|
+
path = url.split(":", 1)[1]
|
|
94
|
+
if path.endswith(".git"):
|
|
95
|
+
path = path[:-4]
|
|
96
|
+
return path
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _find_target_repo(repos):
|
|
103
|
+
cwd_slug = _detect_repo_from_cwd()
|
|
104
|
+
if cwd_slug:
|
|
105
|
+
for r in repos:
|
|
106
|
+
if f"{r['owner']}/{r['name']}" == cwd_slug:
|
|
107
|
+
return r
|
|
108
|
+
return repos[0] if repos else None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _print_setup_snippet(repo):
|
|
112
|
+
owner = repo["owner"]
|
|
113
|
+
name = repo["name"]
|
|
114
|
+
token = repo["token"]
|
|
115
|
+
|
|
116
|
+
print(f"\nadd to your app:\n", file=sys.stderr)
|
|
117
|
+
print(f" pip install git+https://github.com/arowley-predictive-power/ghosttrap-sdk.git\n", file=sys.stderr)
|
|
118
|
+
print(f" import ghosttrap\n", file=sys.stderr)
|
|
119
|
+
print(f" # option 1: token (recommended)", file=sys.stderr)
|
|
120
|
+
print(f' ghosttrap.init("{token}")\n', file=sys.stderr)
|
|
121
|
+
print(f" # option 2: repo url", file=sys.stderr)
|
|
122
|
+
print(f' ghosttrap.init("https://ghosttrap.io/trap/{owner}/{name}/")\n', file=sys.stderr)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def get_gh_token():
|
|
126
|
+
try:
|
|
127
|
+
result = subprocess.run(
|
|
128
|
+
["gh", "auth", "token"],
|
|
129
|
+
capture_output=True, text=True, timeout=10,
|
|
130
|
+
)
|
|
131
|
+
token = result.stdout.strip()
|
|
132
|
+
if result.returncode != 0 or not token:
|
|
133
|
+
print("error: could not get gh auth token. run 'gh auth login' first.", file=sys.stderr)
|
|
134
|
+
sys.exit(1)
|
|
135
|
+
return token
|
|
136
|
+
except FileNotFoundError:
|
|
137
|
+
print("error: gh cli not found. install it from https://cli.github.com", file=sys.stderr)
|
|
138
|
+
sys.exit(1)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _get_repo_token(config):
|
|
142
|
+
"""Get the repo token for the current directory from config."""
|
|
143
|
+
cwd_repo = _detect_repo_from_cwd()
|
|
144
|
+
if cwd_repo and cwd_repo in config.get("repos", {}):
|
|
145
|
+
return config["repos"][cwd_repo]["token"]
|
|
146
|
+
# Fall back to first repo in config
|
|
147
|
+
repos = config.get("repos", {})
|
|
148
|
+
if repos:
|
|
149
|
+
return next(iter(repos.values()))["token"]
|
|
150
|
+
print("error: no repos configured. run 'ghosttrap setup' first.", file=sys.stderr)
|
|
151
|
+
sys.exit(1)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def _connect_and_handle(server_url, token, config, once=False):
|
|
155
|
+
"""Core WebSocket loop. If once=True, exit after the first error."""
|
|
156
|
+
since = config.get("cursor")
|
|
157
|
+
url = f"{server_url}?token={token}"
|
|
158
|
+
if since is not None:
|
|
159
|
+
url += f"&since={since}"
|
|
160
|
+
|
|
161
|
+
async with websockets.connect(url) as ws:
|
|
162
|
+
async for message in ws:
|
|
163
|
+
event = json.loads(message)
|
|
164
|
+
|
|
165
|
+
if event.get("type") == "subscribed":
|
|
166
|
+
repos = event.get("repos", [])
|
|
167
|
+
print(f"watching {len(repos)} repo(s)", file=sys.stderr)
|
|
168
|
+
|
|
169
|
+
new_repos = [r for r in repos if not _is_known_repo(config, r["owner"], r["name"])]
|
|
170
|
+
if new_repos:
|
|
171
|
+
_save_repos(config, repos)
|
|
172
|
+
target = _find_target_repo(new_repos)
|
|
173
|
+
if target:
|
|
174
|
+
_print_setup_snippet(target)
|
|
175
|
+
|
|
176
|
+
if not once:
|
|
177
|
+
print(f"waiting for errors...", file=sys.stderr)
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
if event.get("type") == "error":
|
|
181
|
+
error_id = event.get("error", {}).get("id")
|
|
182
|
+
if error_id is not None:
|
|
183
|
+
config["cursor"] = error_id
|
|
184
|
+
_save_config(config)
|
|
185
|
+
|
|
186
|
+
print(json.dumps(event))
|
|
187
|
+
sys.stdout.flush()
|
|
188
|
+
|
|
189
|
+
if not once:
|
|
190
|
+
error = event["error"]
|
|
191
|
+
print(f"\n{'='*60}", file=sys.stderr)
|
|
192
|
+
print(f" {error.get('repo', '?')}", file=sys.stderr)
|
|
193
|
+
print(f" {error.get('type', '?')}: {error.get('message', '')}", file=sys.stderr)
|
|
194
|
+
frames = error.get("frames", [])
|
|
195
|
+
if frames:
|
|
196
|
+
f = frames[-1]
|
|
197
|
+
print(f" at {f.get('file', '?')}:{f.get('line', '?')} in {f.get('function', '?')}", file=sys.stderr)
|
|
198
|
+
print(f"{'='*60}", file=sys.stderr)
|
|
199
|
+
|
|
200
|
+
if once:
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _require_setup():
|
|
205
|
+
if not os.path.exists(CONFIG_FILE):
|
|
206
|
+
print("error: ghosttrap is not set up. run 'ghosttrap setup' first.", file=sys.stderr)
|
|
207
|
+
sys.exit(1)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _write_skill():
|
|
211
|
+
os.makedirs(SKILL_DIR, exist_ok=True)
|
|
212
|
+
with open(SKILL_FILE, "w") as f:
|
|
213
|
+
f.write(SKILL_CONTENT)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
async def setup(server_url, token):
|
|
217
|
+
config = _load_config()
|
|
218
|
+
|
|
219
|
+
cwd_repo = _detect_repo_from_cwd()
|
|
220
|
+
if not cwd_repo:
|
|
221
|
+
print("error: not in a git repo, or no remote.origin.url configured", file=sys.stderr)
|
|
222
|
+
sys.exit(1)
|
|
223
|
+
|
|
224
|
+
print(f"claiming {cwd_repo}...", file=sys.stderr)
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
url = f"{server_url}?token={token}&repo={cwd_repo}"
|
|
228
|
+
async with websockets.connect(url) as ws:
|
|
229
|
+
message = await asyncio.wait_for(ws.recv(), timeout=30)
|
|
230
|
+
event = json.loads(message)
|
|
231
|
+
|
|
232
|
+
if event.get("type") != "subscribed":
|
|
233
|
+
print("error: unexpected response from server", file=sys.stderr)
|
|
234
|
+
sys.exit(1)
|
|
235
|
+
|
|
236
|
+
repos = event.get("repos", [])
|
|
237
|
+
_save_repos(config, repos)
|
|
238
|
+
_write_skill()
|
|
239
|
+
|
|
240
|
+
target = repos[0] if repos else None
|
|
241
|
+
|
|
242
|
+
print(f"claimed {cwd_repo}", file=sys.stderr)
|
|
243
|
+
print(f"skill file written to {SKILL_FILE}", file=sys.stderr)
|
|
244
|
+
|
|
245
|
+
if target:
|
|
246
|
+
_print_setup_snippet(target)
|
|
247
|
+
|
|
248
|
+
print("done — Claude Code will take it from here\n", file=sys.stderr)
|
|
249
|
+
|
|
250
|
+
except Exception as e:
|
|
251
|
+
print(f"error: {e}", file=sys.stderr)
|
|
252
|
+
sys.exit(1)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
async def watch(server_url, token):
|
|
256
|
+
config = _load_config()
|
|
257
|
+
print(f"connecting to {server_url}...", file=sys.stderr)
|
|
258
|
+
|
|
259
|
+
while True:
|
|
260
|
+
try:
|
|
261
|
+
await _connect_and_handle(server_url, token, config, once=False)
|
|
262
|
+
except websockets.ConnectionClosed:
|
|
263
|
+
print("connection lost, reconnecting...", file=sys.stderr)
|
|
264
|
+
await asyncio.sleep(1)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
async def peek(server_url, token):
|
|
268
|
+
config = _load_config()
|
|
269
|
+
await _connect_and_handle(server_url, token, config, once=True)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def main():
|
|
273
|
+
parser = argparse.ArgumentParser(prog="ghosttrap", description="Watch for errors from ghosttrap.io")
|
|
274
|
+
sub = parser.add_subparsers(dest="command")
|
|
275
|
+
|
|
276
|
+
sub.add_parser("setup", help="Claim repos and install Claude Code skill")
|
|
277
|
+
|
|
278
|
+
watch_parser = sub.add_parser("watch", help="Stream errors in real time")
|
|
279
|
+
watch_parser.add_argument("--server", default=GHOSTTRAP_SERVER, help="WebSocket server URL")
|
|
280
|
+
|
|
281
|
+
peek_parser = sub.add_parser("peek", help="Wait for the next error then exit")
|
|
282
|
+
peek_parser.add_argument("--server", default=GHOSTTRAP_SERVER, help="WebSocket server URL")
|
|
283
|
+
|
|
284
|
+
args = parser.parse_args()
|
|
285
|
+
|
|
286
|
+
if args.command == "setup":
|
|
287
|
+
token = get_gh_token()
|
|
288
|
+
asyncio.run(setup(GHOSTTRAP_SERVER, token))
|
|
289
|
+
elif args.command == "watch":
|
|
290
|
+
_require_setup()
|
|
291
|
+
config = _load_config()
|
|
292
|
+
token = _get_repo_token(config)
|
|
293
|
+
asyncio.run(watch(args.server, token))
|
|
294
|
+
elif args.command == "peek":
|
|
295
|
+
_require_setup()
|
|
296
|
+
config = _load_config()
|
|
297
|
+
token = _get_repo_token(config)
|
|
298
|
+
asyncio.run(peek(args.server, token))
|
|
299
|
+
else:
|
|
300
|
+
parser.print_help()
|
|
301
|
+
sys.exit(1)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
if __name__ == "__main__":
|
|
305
|
+
main()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ghosttrap-cli
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Watch for errors streaming from ghosttrap.io
|
|
5
|
+
Project-URL: Homepage, https://github.com/arowley-predictive-power/ghosttrap-cli
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: websockets>=12.0
|
|
9
|
+
|
|
10
|
+
# ghosttrap-cli
|
|
11
|
+
|
|
12
|
+
Watch for errors streaming from ghosttrap.io in real time.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
pip install ghosttrap-cli
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Requires the [GitHub CLI](https://cli.github.com) (`gh`) for authentication.
|
|
21
|
+
|
|
22
|
+
## Use
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
ghosttrap watch
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Authenticates via your local `gh` session, subscribes to error streams
|
|
29
|
+
for all repos you have access to, and prints events as they arrive.
|
|
30
|
+
|
|
31
|
+
Human-readable summaries go to stderr. Machine-readable JSON goes to
|
|
32
|
+
stdout, for piping into other tools.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
ghosttrap_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
ghosttrap_cli/cli.py,sha256=8bcKFunBBlNlqLUj7r42kyhgXD6OArla9S_o4eAZpOU,10988
|
|
3
|
+
ghosttrap_cli-0.3.0.dist-info/METADATA,sha256=xq_jj3RPp-C_0iiw7XiiLOG6T-qsANStirWUmLgN0zE,770
|
|
4
|
+
ghosttrap_cli-0.3.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
5
|
+
ghosttrap_cli-0.3.0.dist-info/entry_points.txt,sha256=XlI6WLes0vBfQci4nSN_cW4WPq4xECXTiBmCVZ9MKBY,53
|
|
6
|
+
ghosttrap_cli-0.3.0.dist-info/top_level.txt,sha256=jbMbkewrtqUrtEmcu6RgxbIyy6gcPiFMd-Zg-3h0ELc,14
|
|
7
|
+
ghosttrap_cli-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ghosttrap_cli
|