ghosttrap-cli 0.3.16__tar.gz → 0.3.18__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ghosttrap-cli
3
- Version: 0.3.16
3
+ Version: 0.3.18
4
4
  Summary: Watch for errors streaming from ghosttrap.io
5
5
  Project-URL: Homepage, https://github.com/alex-rowley/ghosttrap-cli
6
6
  Requires-Python: >=3.10
@@ -41,6 +41,8 @@ After fixing, Claude restarts peek and waits for the next one. Errors become a r
41
41
 
42
42
  Your app needs [ghosttrap-sdk](https://github.com/alex-rowley/ghosttrap-sdk) to report errors. The Claude Code skill handles the integration automatically — it installs the SDK, wires it into your app, and adds Django/Celery hooks if applicable. You shouldn't need to touch the SDK manually.
43
43
 
44
+ If you want to manually flag a caught exception or a non-exception condition, call `ghosttrap.trap(exc_or_message)` from your app code — see the [SDK README](https://github.com/alex-rowley/ghosttrap-sdk#manually-trap-an-event).
45
+
44
46
  ## Commands
45
47
 
46
48
  | Command | What it does |
@@ -51,6 +53,8 @@ Your app needs [ghosttrap-sdk](https://github.com/alex-rowley/ghosttrap-sdk) to
51
53
  | `ghosttrap last` | Fetch the most recent error and exit (no waiting) |
52
54
  | `ghosttrap last --clear` | Fetch the most recent error and skip everything older |
53
55
  | `ghosttrap watch` | Stream all errors continuously |
56
+ | `ghosttrap list [n]` | Print a numbered summary of the most recent `n` errors (default 10, max 50). Doesn't move the cursor. |
57
+ | `ghosttrap show <i>` | Full details for row `i` from the last `list` (1-based). Doesn't move the cursor. |
54
58
  | `ghosttrap clear` | Skip all outstanding errors |
55
59
  | `ghosttrap nuke` | Permanently delete every server-side row for the current repo (errors + token). Requires typed confirmation. |
56
60
 
@@ -32,6 +32,8 @@ After fixing, Claude restarts peek and waits for the next one. Errors become a r
32
32
 
33
33
  Your app needs [ghosttrap-sdk](https://github.com/alex-rowley/ghosttrap-sdk) to report errors. The Claude Code skill handles the integration automatically — it installs the SDK, wires it into your app, and adds Django/Celery hooks if applicable. You shouldn't need to touch the SDK manually.
34
34
 
35
+ If you want to manually flag a caught exception or a non-exception condition, call `ghosttrap.trap(exc_or_message)` from your app code — see the [SDK README](https://github.com/alex-rowley/ghosttrap-sdk#manually-trap-an-event).
36
+
35
37
  ## Commands
36
38
 
37
39
  | Command | What it does |
@@ -42,6 +44,8 @@ Your app needs [ghosttrap-sdk](https://github.com/alex-rowley/ghosttrap-sdk) to
42
44
  | `ghosttrap last` | Fetch the most recent error and exit (no waiting) |
43
45
  | `ghosttrap last --clear` | Fetch the most recent error and skip everything older |
44
46
  | `ghosttrap watch` | Stream all errors continuously |
47
+ | `ghosttrap list [n]` | Print a numbered summary of the most recent `n` errors (default 10, max 50). Doesn't move the cursor. |
48
+ | `ghosttrap show <i>` | Full details for row `i` from the last `list` (1-based). Doesn't move the cursor. |
45
49
  | `ghosttrap clear` | Skip all outstanding errors |
46
50
  | `ghosttrap nuke` | Permanently delete every server-side row for the current repo (errors + token). Requires typed confirmation. |
47
51
 
@@ -2,29 +2,18 @@
2
2
 
3
3
  import argparse
4
4
  import asyncio
5
- import hashlib
6
5
  import json
7
6
  import os
8
7
  import subprocess
9
8
  import sys
9
+ import tempfile
10
10
  import time
11
+ import urllib.error
11
12
  import urllib.request
12
13
 
13
14
  import websockets
14
15
 
15
- KNOWN_SKILL_HASHES = {
16
- "aeda67bc5971bd8af4d7ebe819ebcce5acead562fa618227a1798b4b5ae7143e", # v0.2.0
17
- "0f2d2f4105e393fc69084d404d5a8154ba5d97fd23f92810c51345e3dc68e9a0", # v0.3.0
18
- "8564b65b8ab5c63283cda1706e30ca62bc4e111d33ba8918220f4b556ad01da1", # v0.3.1..v0.3.3
19
- "5759b2e0dc8ca47c3801915fd688cc8da878a7ab8d405f5183ffd7e8c8df4c55", # v0.3.4..v0.3.7
20
- "0651bb4247cf5c68960ff5b63d6a5d0c85ff1ce08e7966ab4823601ff02cf1f4", # v0.3.9
21
- "38810f43867a2a91420cc3dacbc71d2acabd7125596fd5b43f222b49725c9696", # v0.3.10
22
- "19b67d913dc5214ee4db3610bd8749da67324c174b904b5da71ee6de13e23e63", # v0.3.11
23
- "bf7768c3de266b7018d5c722c6c9991b487e7897786b3a406c460842cdcde8b5", # v0.3.12
24
- "d3f594c4c3601a4594c18ebc5e16dfd4abad4ab97d56285ecc20634b919b6731", # v0.3.14
25
- }
26
-
27
- __version__ = "0.3.16"
16
+ __version__ = "0.3.18"
28
17
 
29
18
  GHOSTTRAP_SERVER = "wss://ghosttrap.io/stream/"
30
19
  CONFIG_DIR = os.path.expanduser("~/.ghosttrap")
@@ -74,6 +63,10 @@ Read `~/.ghosttrap/config.json` for state. It contains:
74
63
  3. If `sdk_installed` is false or missing: install the SDK (`pip install ghosttrap-sdk`), 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. Only pass `send_user=True` to `init()` if the user explicitly asks for user context in reports — it's PII and stays off by default.
75
64
  4. Run `ghosttrap peek --clear` with `run_in_background: true`. The `--clear` flag skips any stale backlog from prior sessions so you only get fresh errors.
76
65
 
66
+ ## Manual capture
67
+
68
+ For caught exceptions or non-exception conditions the user explicitly wants reported, use `ghosttrap.trap(exc_or_message)` from app code — pass an exception instance or a string. Synthetic string events arrive as type `TrappedEvent` with the caller's stack. Only wire this in when the user asks for it; don't add `trap()` calls speculatively.
69
+
77
70
  ## When peek returns
78
71
 
79
72
  1. **Immediately restart peek** in the background before doing anything else — this ensures you're listening for the next error while you work on the current one. Use plain `ghosttrap peek` here (no `--clear`) — you only want to skip backlog at session start.
@@ -83,6 +76,8 @@ Read `~/.ghosttrap/config.json` for state. It contains:
83
76
  ## Other commands
84
77
 
85
78
  - `ghosttrap last` — fetch the single most recent error and exit immediately, no waiting. Useful when the user wants to look at the latest error without starting a watch. Add `--clear` to also skip everything older in one shot.
79
+ - `ghosttrap list [n]` — print a numbered summary of the most recent `n` errors (default 10, max 50). Does not move the cursor. Caches the ordered ids in config so a follow-up `ghosttrap show <i>` returns full details for that row.
80
+ - `ghosttrap show <i>` — full details for the i-th row from the most recent `ghosttrap list`. Does not move the cursor.
86
81
  - `ghosttrap clear` — manually skip outstanding errors without waiting. Useful if the user explicitly wants to drop the queue.
87
82
  - `ghosttrap nuke` — permanently delete every server-side row for the current repo (errors + the Repo row + its token). Requires the user to type the repo name `owner/name` to confirm. Only run if the user explicitly asks to wipe server data — never proactively. After it succeeds the token is dead; the user would need to `ghosttrap setup` again to use this repo.
88
83
 
@@ -201,26 +196,33 @@ def get_gh_token():
201
196
  sys.exit(1)
202
197
 
203
198
 
204
- def _get_repo_token(config, requested=None):
205
- """Get the repo token. If `requested` is 'owner/name', match strictly. Else cwd, else first."""
199
+ def _get_repo_entry(config, requested=None):
200
+ """Return (key, entry) for the chosen repo. Same resolution rules as _get_repo_token."""
206
201
  repos = config.get("repos", {})
207
202
  if not repos:
208
203
  print("error: no repos configured. run 'ghosttrap setup' first.", file=sys.stderr)
209
204
  sys.exit(1)
210
205
  if requested:
211
- for entry in repos.values():
206
+ for k, entry in repos.items():
212
207
  if f"{entry.get('owner')}/{entry.get('name')}" == requested:
213
- return entry["token"]
208
+ return k, entry
214
209
  available = sorted(f"{e['owner']}/{e['name']}" for e in repos.values())
215
210
  print(f"error: '{requested}' is not in your config.", file=sys.stderr)
216
211
  print(f"available: {', '.join(available)}", file=sys.stderr)
217
212
  sys.exit(1)
218
213
  cwd_repo = _detect_repo_from_cwd()
219
214
  if cwd_repo:
220
- for entry in repos.values():
215
+ for k, entry in repos.items():
221
216
  if f"{entry.get('owner')}/{entry.get('name')}" == cwd_repo:
222
- return entry["token"]
223
- return next(iter(repos.values()))["token"]
217
+ return k, entry
218
+ k = next(iter(repos))
219
+ return k, repos[k]
220
+
221
+
222
+ def _get_repo_token(config, requested=None):
223
+ """Get the repo token. If `requested` is 'owner/name', match strictly. Else cwd, else first."""
224
+ _, entry = _get_repo_entry(config, requested)
225
+ return entry["token"]
224
226
 
225
227
 
226
228
  async def _connect_and_handle(server_url, token, config, once=False):
@@ -295,22 +297,69 @@ def _require_setup():
295
297
  sys.exit(1)
296
298
 
297
299
 
298
- def _write_skill():
300
+ def _write_skill(config=None):
299
301
  os.makedirs(SKILL_DIR, exist_ok=True)
300
302
  with open(SKILL_FILE, "w") as f:
301
303
  f.write(SKILL_CONTENT)
304
+ if config is None:
305
+ config = _load_config()
306
+ config["skill_baseline"] = SKILL_CONTENT
307
+ _save_config(config)
308
+
309
+
310
+ def _merge_skill(base, local, remote):
311
+ """3-way merge via `git merge-file -p`. Returns (merged_text, clean)."""
312
+ with tempfile.TemporaryDirectory() as d:
313
+ bp = os.path.join(d, "base")
314
+ lp = os.path.join(d, "local")
315
+ rp = os.path.join(d, "remote")
316
+ for path, text in [(bp, base), (lp, local), (rp, remote)]:
317
+ with open(path, "w") as f:
318
+ f.write(text)
319
+ result = subprocess.run(
320
+ ["git", "merge-file", "-p",
321
+ "-L", "your edits", "-L", "previous release", "-L", "new release",
322
+ lp, bp, rp],
323
+ capture_output=True, text=True,
324
+ )
325
+ return result.stdout, result.returncode == 0
302
326
 
303
327
 
304
328
  def _refresh_skill_if_stale():
305
329
  if not os.path.exists(SKILL_FILE):
306
330
  return
307
331
  with open(SKILL_FILE) as f:
308
- content = f.read()
309
- if content == SKILL_CONTENT:
332
+ on_disk = f.read()
333
+ if on_disk == SKILL_CONTENT:
334
+ return
335
+ config = _load_config()
336
+ baseline = config.get("skill_baseline")
337
+ if baseline is None:
338
+ # Pre-baseline install: adopt current on-disk content as the baseline
339
+ # so future releases can 3-way-merge instead of clobbering local edits.
340
+ config["skill_baseline"] = on_disk
341
+ _save_config(config)
310
342
  return
311
- if hashlib.sha256(content.encode()).hexdigest() in KNOWN_SKILL_HASHES:
312
- _write_skill()
343
+ if baseline == on_disk:
344
+ _write_skill(config)
313
345
  print("ghosttrap skill file updated", file=sys.stderr)
346
+ return
347
+ merged, clean = _merge_skill(baseline, on_disk, SKILL_CONTENT)
348
+ if clean:
349
+ with open(SKILL_FILE, "w") as f:
350
+ f.write(merged)
351
+ config["skill_baseline"] = SKILL_CONTENT
352
+ _save_config(config)
353
+ print("ghosttrap skill file updated (merged with your local edits)", file=sys.stderr)
354
+ return
355
+ new_path = SKILL_FILE + ".new"
356
+ with open(new_path, "w") as f:
357
+ f.write(merged)
358
+ print(
359
+ f"ghosttrap skill update has conflicts with your local edits; "
360
+ f"merged candidate at {new_path} — resolve, copy to {SKILL_FILE}, and rerun.",
361
+ file=sys.stderr,
362
+ )
314
363
 
315
364
 
316
365
  async def setup(server_url, token):
@@ -339,7 +388,7 @@ async def setup(server_url, token):
339
388
 
340
389
  repos = event.get("repos", [])
341
390
  _save_repos(config, repos)
342
- _write_skill()
391
+ _write_skill(config)
343
392
 
344
393
  target = repos[0] if repos else None
345
394
 
@@ -464,6 +513,126 @@ def nuke():
464
513
  _save_config(config)
465
514
 
466
515
 
516
+ def _rel_time(iso):
517
+ if not iso:
518
+ return "?"
519
+ try:
520
+ from datetime import datetime, timezone
521
+ dt = datetime.fromisoformat(iso)
522
+ if dt.tzinfo is None:
523
+ dt = dt.replace(tzinfo=timezone.utc)
524
+ delta = datetime.now(timezone.utc) - dt
525
+ s = int(delta.total_seconds())
526
+ if s < 60:
527
+ return f"{s}s ago"
528
+ if s < 3600:
529
+ return f"{s // 60}m ago"
530
+ if s < 86400:
531
+ return f"{s // 3600}h ago"
532
+ if s < 86400 * 30:
533
+ return f"{s // 86400}d ago"
534
+ return dt.strftime("%Y-%m-%d")
535
+ except Exception:
536
+ return iso
537
+
538
+
539
+ def _print_error_details(error):
540
+ print(json.dumps({"type": "error", "error": error}))
541
+ sys.stdout.flush()
542
+ print(f"\n{'='*60}", file=sys.stderr)
543
+ print(f" {error.get('repo', '?')}", file=sys.stderr)
544
+ print(f" {error.get('type', '?')}: {error.get('message', '')}", file=sys.stderr)
545
+ frames = error.get("frames", [])
546
+ if frames:
547
+ f = frames[-1]
548
+ print(f" at {f.get('file', '?')}:{f.get('line', '?')} in {f.get('function', '?')}", file=sys.stderr)
549
+ print(f"{'='*60}", file=sys.stderr)
550
+
551
+
552
+ def list_recent(n=10, requested=None):
553
+ _require_setup()
554
+ config = _load_config()
555
+ _check_cli_version(config)
556
+ n = max(1, min(int(n), 50))
557
+ key, entry = _get_repo_entry(config, requested)
558
+ token = entry["token"]
559
+ server = GHOSTTRAP_SERVER.replace("wss://", "https://").replace("/stream/", "")
560
+ url = f"{server}/list/{token}/?n={n}"
561
+ try:
562
+ req = urllib.request.Request(url, headers={"User-Agent": "ghosttrap-cli"})
563
+ with urllib.request.urlopen(req, timeout=10) as resp:
564
+ data = json.loads(resp.read())
565
+ except Exception as e:
566
+ print(f"error: {e}", file=sys.stderr)
567
+ sys.exit(1)
568
+
569
+ errors = data.get("errors", [])
570
+ if not errors:
571
+ print("no errors yet", file=sys.stderr)
572
+ entry["recent"] = []
573
+ _save_config(config)
574
+ return
575
+
576
+ entry["recent"] = [e["id"] for e in errors]
577
+ _save_config(config)
578
+
579
+ width = len(str(len(errors)))
580
+ for i, e in enumerate(errors, 1):
581
+ when = _rel_time(e.get("created_at"))
582
+ etype = e.get("type") or "?"
583
+ msg = (e.get("message") or "").splitlines()[0] if e.get("message") else ""
584
+ if len(msg) > 60:
585
+ msg = msg[:57] + "..."
586
+ loc = ""
587
+ if e.get("file"):
588
+ loc = f"{e['file']}:{e.get('line', '?')}"
589
+ if e.get("function"):
590
+ loc += f" ({e['function']})"
591
+ print(f" {i:>{width}} {when:<12} {etype:<20} {msg:<60} {loc}")
592
+ print(f"\nrun 'ghosttrap show <n>' to see full details. cursor unchanged.", file=sys.stderr)
593
+
594
+
595
+ def show(index, requested=None):
596
+ _require_setup()
597
+ config = _load_config()
598
+ _check_cli_version(config)
599
+ key, entry = _get_repo_entry(config, requested)
600
+ recent = entry.get("recent") or []
601
+ if not recent:
602
+ print("error: no recent list cached. run 'ghosttrap list' first.", file=sys.stderr)
603
+ sys.exit(1)
604
+ try:
605
+ i = int(index)
606
+ except (TypeError, ValueError):
607
+ print(f"error: '{index}' is not a number.", file=sys.stderr)
608
+ sys.exit(1)
609
+ if i < 1 or i > len(recent):
610
+ print(f"error: index out of range. last list had {len(recent)} entries.", file=sys.stderr)
611
+ sys.exit(1)
612
+ db_id = recent[i - 1]
613
+ token = entry["token"]
614
+ server = GHOSTTRAP_SERVER.replace("wss://", "https://").replace("/stream/", "")
615
+ url = f"{server}/error/{token}/{db_id}/"
616
+ try:
617
+ req = urllib.request.Request(url, headers={"User-Agent": "ghosttrap-cli"})
618
+ with urllib.request.urlopen(req, timeout=10) as resp:
619
+ data = json.loads(resp.read())
620
+ except urllib.error.HTTPError as e:
621
+ if e.code == 404:
622
+ print(f"error: this error no longer exists on the server (id #{db_id}).", file=sys.stderr)
623
+ else:
624
+ print(f"error: {e}", file=sys.stderr)
625
+ sys.exit(1)
626
+ except Exception as e:
627
+ print(f"error: {e}", file=sys.stderr)
628
+ sys.exit(1)
629
+ error = data.get("error")
630
+ if not error:
631
+ print(f"error: empty response", file=sys.stderr)
632
+ sys.exit(1)
633
+ _print_error_details(error)
634
+
635
+
467
636
  def last(do_clear=False, requested=None):
468
637
  _require_setup()
469
638
  config = _load_config()
@@ -484,17 +653,7 @@ def last(do_clear=False, requested=None):
484
653
  print("no errors yet", file=sys.stderr)
485
654
  return
486
655
 
487
- print(json.dumps({"type": "error", "error": error}))
488
- sys.stdout.flush()
489
-
490
- print(f"\n{'='*60}", file=sys.stderr)
491
- print(f" {error.get('repo', '?')}", file=sys.stderr)
492
- print(f" {error.get('type', '?')}: {error.get('message', '')}", file=sys.stderr)
493
- frames = error.get("frames", [])
494
- if frames:
495
- f = frames[-1]
496
- print(f" at {f.get('file', '?')}:{f.get('line', '?')} in {f.get('function', '?')}", file=sys.stderr)
497
- print(f"{'='*60}", file=sys.stderr)
656
+ _print_error_details(error)
498
657
 
499
658
  if do_clear:
500
659
  try:
@@ -526,6 +685,14 @@ def main():
526
685
  last_parser.add_argument("--clear", action="store_true", help="Also skip remaining outstanding errors")
527
686
  last_parser.add_argument("--repo", help="Target repo as owner/name (overrides cwd detection)")
528
687
 
688
+ list_parser = sub.add_parser("list", help="List the most recent N errors (summary only, cursor unchanged)")
689
+ list_parser.add_argument("n", nargs="?", type=int, default=10, help="How many to list (default 10, max 50)")
690
+ list_parser.add_argument("--repo", help="Target repo as owner/name (overrides cwd detection)")
691
+
692
+ show_parser = sub.add_parser("show", help="Show full details for an index from the last 'list' (cursor unchanged)")
693
+ show_parser.add_argument("index", type=int, help="1-based index from the last 'ghosttrap list'")
694
+ show_parser.add_argument("--repo", help="Target repo as owner/name (overrides cwd detection)")
695
+
529
696
  sub.add_parser("nuke", help="Permanently delete all server data for the current repo")
530
697
 
531
698
  args = parser.parse_args()
@@ -556,6 +723,12 @@ def main():
556
723
  elif args.command == "last":
557
724
  _refresh_skill_if_stale()
558
725
  last(do_clear=args.clear, requested=args.repo)
726
+ elif args.command == "list":
727
+ _refresh_skill_if_stale()
728
+ list_recent(n=args.n, requested=args.repo)
729
+ elif args.command == "show":
730
+ _refresh_skill_if_stale()
731
+ show(args.index, requested=args.repo)
559
732
  elif args.command == "nuke":
560
733
  nuke()
561
734
  else:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ghosttrap-cli
3
- Version: 0.3.16
3
+ Version: 0.3.18
4
4
  Summary: Watch for errors streaming from ghosttrap.io
5
5
  Project-URL: Homepage, https://github.com/alex-rowley/ghosttrap-cli
6
6
  Requires-Python: >=3.10
@@ -41,6 +41,8 @@ After fixing, Claude restarts peek and waits for the next one. Errors become a r
41
41
 
42
42
  Your app needs [ghosttrap-sdk](https://github.com/alex-rowley/ghosttrap-sdk) to report errors. The Claude Code skill handles the integration automatically — it installs the SDK, wires it into your app, and adds Django/Celery hooks if applicable. You shouldn't need to touch the SDK manually.
43
43
 
44
+ If you want to manually flag a caught exception or a non-exception condition, call `ghosttrap.trap(exc_or_message)` from your app code — see the [SDK README](https://github.com/alex-rowley/ghosttrap-sdk#manually-trap-an-event).
45
+
44
46
  ## Commands
45
47
 
46
48
  | Command | What it does |
@@ -51,6 +53,8 @@ Your app needs [ghosttrap-sdk](https://github.com/alex-rowley/ghosttrap-sdk) to
51
53
  | `ghosttrap last` | Fetch the most recent error and exit (no waiting) |
52
54
  | `ghosttrap last --clear` | Fetch the most recent error and skip everything older |
53
55
  | `ghosttrap watch` | Stream all errors continuously |
56
+ | `ghosttrap list [n]` | Print a numbered summary of the most recent `n` errors (default 10, max 50). Doesn't move the cursor. |
57
+ | `ghosttrap show <i>` | Full details for row `i` from the last `list` (1-based). Doesn't move the cursor. |
54
58
  | `ghosttrap clear` | Skip all outstanding errors |
55
59
  | `ghosttrap nuke` | Permanently delete every server-side row for the current repo (errors + token). Requires typed confirmation. |
56
60
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ghosttrap-cli"
7
- version = "0.3.16"
7
+ version = "0.3.18"
8
8
  description = "Watch for errors streaming from ghosttrap.io"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
File without changes