ghosttrap-cli 0.3.16__tar.gz → 0.3.17__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.17
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
@@ -51,6 +51,8 @@ Your app needs [ghosttrap-sdk](https://github.com/alex-rowley/ghosttrap-sdk) to
51
51
  | `ghosttrap last` | Fetch the most recent error and exit (no waiting) |
52
52
  | `ghosttrap last --clear` | Fetch the most recent error and skip everything older |
53
53
  | `ghosttrap watch` | Stream all errors continuously |
54
+ | `ghosttrap list [n]` | Print a numbered summary of the most recent `n` errors (default 10, max 50). Doesn't move the cursor. |
55
+ | `ghosttrap show <i>` | Full details for row `i` from the last `list` (1-based). Doesn't move the cursor. |
54
56
  | `ghosttrap clear` | Skip all outstanding errors |
55
57
  | `ghosttrap nuke` | Permanently delete every server-side row for the current repo (errors + token). Requires typed confirmation. |
56
58
 
@@ -42,6 +42,8 @@ Your app needs [ghosttrap-sdk](https://github.com/alex-rowley/ghosttrap-sdk) to
42
42
  | `ghosttrap last` | Fetch the most recent error and exit (no waiting) |
43
43
  | `ghosttrap last --clear` | Fetch the most recent error and skip everything older |
44
44
  | `ghosttrap watch` | Stream all errors continuously |
45
+ | `ghosttrap list [n]` | Print a numbered summary of the most recent `n` errors (default 10, max 50). Doesn't move the cursor. |
46
+ | `ghosttrap show <i>` | Full details for row `i` from the last `list` (1-based). Doesn't move the cursor. |
45
47
  | `ghosttrap clear` | Skip all outstanding errors |
46
48
  | `ghosttrap nuke` | Permanently delete every server-side row for the current repo (errors + token). Requires typed confirmation. |
47
49
 
@@ -8,6 +8,7 @@ import os
8
8
  import subprocess
9
9
  import sys
10
10
  import time
11
+ import urllib.error
11
12
  import urllib.request
12
13
 
13
14
  import websockets
@@ -22,9 +23,10 @@ KNOWN_SKILL_HASHES = {
22
23
  "19b67d913dc5214ee4db3610bd8749da67324c174b904b5da71ee6de13e23e63", # v0.3.11
23
24
  "bf7768c3de266b7018d5c722c6c9991b487e7897786b3a406c460842cdcde8b5", # v0.3.12
24
25
  "d3f594c4c3601a4594c18ebc5e16dfd4abad4ab97d56285ecc20634b919b6731", # v0.3.14
26
+ "1c3a0507fab027abc0f176ce2fd88cad28276bef4fcef46cdb497f53967346e1", # v0.3.16
25
27
  }
26
28
 
27
- __version__ = "0.3.16"
29
+ __version__ = "0.3.17"
28
30
 
29
31
  GHOSTTRAP_SERVER = "wss://ghosttrap.io/stream/"
30
32
  CONFIG_DIR = os.path.expanduser("~/.ghosttrap")
@@ -83,6 +85,8 @@ Read `~/.ghosttrap/config.json` for state. It contains:
83
85
  ## Other commands
84
86
 
85
87
  - `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.
88
+ - `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.
89
+ - `ghosttrap show <i>` — full details for the i-th row from the most recent `ghosttrap list`. Does not move the cursor.
86
90
  - `ghosttrap clear` — manually skip outstanding errors without waiting. Useful if the user explicitly wants to drop the queue.
87
91
  - `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
92
 
@@ -201,26 +205,33 @@ def get_gh_token():
201
205
  sys.exit(1)
202
206
 
203
207
 
204
- def _get_repo_token(config, requested=None):
205
- """Get the repo token. If `requested` is 'owner/name', match strictly. Else cwd, else first."""
208
+ def _get_repo_entry(config, requested=None):
209
+ """Return (key, entry) for the chosen repo. Same resolution rules as _get_repo_token."""
206
210
  repos = config.get("repos", {})
207
211
  if not repos:
208
212
  print("error: no repos configured. run 'ghosttrap setup' first.", file=sys.stderr)
209
213
  sys.exit(1)
210
214
  if requested:
211
- for entry in repos.values():
215
+ for k, entry in repos.items():
212
216
  if f"{entry.get('owner')}/{entry.get('name')}" == requested:
213
- return entry["token"]
217
+ return k, entry
214
218
  available = sorted(f"{e['owner']}/{e['name']}" for e in repos.values())
215
219
  print(f"error: '{requested}' is not in your config.", file=sys.stderr)
216
220
  print(f"available: {', '.join(available)}", file=sys.stderr)
217
221
  sys.exit(1)
218
222
  cwd_repo = _detect_repo_from_cwd()
219
223
  if cwd_repo:
220
- for entry in repos.values():
224
+ for k, entry in repos.items():
221
225
  if f"{entry.get('owner')}/{entry.get('name')}" == cwd_repo:
222
- return entry["token"]
223
- return next(iter(repos.values()))["token"]
226
+ return k, entry
227
+ k = next(iter(repos))
228
+ return k, repos[k]
229
+
230
+
231
+ def _get_repo_token(config, requested=None):
232
+ """Get the repo token. If `requested` is 'owner/name', match strictly. Else cwd, else first."""
233
+ _, entry = _get_repo_entry(config, requested)
234
+ return entry["token"]
224
235
 
225
236
 
226
237
  async def _connect_and_handle(server_url, token, config, once=False):
@@ -464,6 +475,126 @@ def nuke():
464
475
  _save_config(config)
465
476
 
466
477
 
478
+ def _rel_time(iso):
479
+ if not iso:
480
+ return "?"
481
+ try:
482
+ from datetime import datetime, timezone
483
+ dt = datetime.fromisoformat(iso)
484
+ if dt.tzinfo is None:
485
+ dt = dt.replace(tzinfo=timezone.utc)
486
+ delta = datetime.now(timezone.utc) - dt
487
+ s = int(delta.total_seconds())
488
+ if s < 60:
489
+ return f"{s}s ago"
490
+ if s < 3600:
491
+ return f"{s // 60}m ago"
492
+ if s < 86400:
493
+ return f"{s // 3600}h ago"
494
+ if s < 86400 * 30:
495
+ return f"{s // 86400}d ago"
496
+ return dt.strftime("%Y-%m-%d")
497
+ except Exception:
498
+ return iso
499
+
500
+
501
+ def _print_error_details(error):
502
+ print(json.dumps({"type": "error", "error": error}))
503
+ sys.stdout.flush()
504
+ print(f"\n{'='*60}", file=sys.stderr)
505
+ print(f" {error.get('repo', '?')}", file=sys.stderr)
506
+ print(f" {error.get('type', '?')}: {error.get('message', '')}", file=sys.stderr)
507
+ frames = error.get("frames", [])
508
+ if frames:
509
+ f = frames[-1]
510
+ print(f" at {f.get('file', '?')}:{f.get('line', '?')} in {f.get('function', '?')}", file=sys.stderr)
511
+ print(f"{'='*60}", file=sys.stderr)
512
+
513
+
514
+ def list_recent(n=10, requested=None):
515
+ _require_setup()
516
+ config = _load_config()
517
+ _check_cli_version(config)
518
+ n = max(1, min(int(n), 50))
519
+ key, entry = _get_repo_entry(config, requested)
520
+ token = entry["token"]
521
+ server = GHOSTTRAP_SERVER.replace("wss://", "https://").replace("/stream/", "")
522
+ url = f"{server}/list/{token}/?n={n}"
523
+ try:
524
+ req = urllib.request.Request(url, headers={"User-Agent": "ghosttrap-cli"})
525
+ with urllib.request.urlopen(req, timeout=10) as resp:
526
+ data = json.loads(resp.read())
527
+ except Exception as e:
528
+ print(f"error: {e}", file=sys.stderr)
529
+ sys.exit(1)
530
+
531
+ errors = data.get("errors", [])
532
+ if not errors:
533
+ print("no errors yet", file=sys.stderr)
534
+ entry["recent"] = []
535
+ _save_config(config)
536
+ return
537
+
538
+ entry["recent"] = [e["id"] for e in errors]
539
+ _save_config(config)
540
+
541
+ width = len(str(len(errors)))
542
+ for i, e in enumerate(errors, 1):
543
+ when = _rel_time(e.get("created_at"))
544
+ etype = e.get("type") or "?"
545
+ msg = (e.get("message") or "").splitlines()[0] if e.get("message") else ""
546
+ if len(msg) > 60:
547
+ msg = msg[:57] + "..."
548
+ loc = ""
549
+ if e.get("file"):
550
+ loc = f"{e['file']}:{e.get('line', '?')}"
551
+ if e.get("function"):
552
+ loc += f" ({e['function']})"
553
+ print(f" {i:>{width}} {when:<12} {etype:<20} {msg:<60} {loc}")
554
+ print(f"\nrun 'ghosttrap show <n>' to see full details. cursor unchanged.", file=sys.stderr)
555
+
556
+
557
+ def show(index, requested=None):
558
+ _require_setup()
559
+ config = _load_config()
560
+ _check_cli_version(config)
561
+ key, entry = _get_repo_entry(config, requested)
562
+ recent = entry.get("recent") or []
563
+ if not recent:
564
+ print("error: no recent list cached. run 'ghosttrap list' first.", file=sys.stderr)
565
+ sys.exit(1)
566
+ try:
567
+ i = int(index)
568
+ except (TypeError, ValueError):
569
+ print(f"error: '{index}' is not a number.", file=sys.stderr)
570
+ sys.exit(1)
571
+ if i < 1 or i > len(recent):
572
+ print(f"error: index out of range. last list had {len(recent)} entries.", file=sys.stderr)
573
+ sys.exit(1)
574
+ db_id = recent[i - 1]
575
+ token = entry["token"]
576
+ server = GHOSTTRAP_SERVER.replace("wss://", "https://").replace("/stream/", "")
577
+ url = f"{server}/error/{token}/{db_id}/"
578
+ try:
579
+ req = urllib.request.Request(url, headers={"User-Agent": "ghosttrap-cli"})
580
+ with urllib.request.urlopen(req, timeout=10) as resp:
581
+ data = json.loads(resp.read())
582
+ except urllib.error.HTTPError as e:
583
+ if e.code == 404:
584
+ print(f"error: this error no longer exists on the server (id #{db_id}).", file=sys.stderr)
585
+ else:
586
+ print(f"error: {e}", file=sys.stderr)
587
+ sys.exit(1)
588
+ except Exception as e:
589
+ print(f"error: {e}", file=sys.stderr)
590
+ sys.exit(1)
591
+ error = data.get("error")
592
+ if not error:
593
+ print(f"error: empty response", file=sys.stderr)
594
+ sys.exit(1)
595
+ _print_error_details(error)
596
+
597
+
467
598
  def last(do_clear=False, requested=None):
468
599
  _require_setup()
469
600
  config = _load_config()
@@ -484,17 +615,7 @@ def last(do_clear=False, requested=None):
484
615
  print("no errors yet", file=sys.stderr)
485
616
  return
486
617
 
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)
618
+ _print_error_details(error)
498
619
 
499
620
  if do_clear:
500
621
  try:
@@ -526,6 +647,14 @@ def main():
526
647
  last_parser.add_argument("--clear", action="store_true", help="Also skip remaining outstanding errors")
527
648
  last_parser.add_argument("--repo", help="Target repo as owner/name (overrides cwd detection)")
528
649
 
650
+ list_parser = sub.add_parser("list", help="List the most recent N errors (summary only, cursor unchanged)")
651
+ list_parser.add_argument("n", nargs="?", type=int, default=10, help="How many to list (default 10, max 50)")
652
+ list_parser.add_argument("--repo", help="Target repo as owner/name (overrides cwd detection)")
653
+
654
+ show_parser = sub.add_parser("show", help="Show full details for an index from the last 'list' (cursor unchanged)")
655
+ show_parser.add_argument("index", type=int, help="1-based index from the last 'ghosttrap list'")
656
+ show_parser.add_argument("--repo", help="Target repo as owner/name (overrides cwd detection)")
657
+
529
658
  sub.add_parser("nuke", help="Permanently delete all server data for the current repo")
530
659
 
531
660
  args = parser.parse_args()
@@ -556,6 +685,12 @@ def main():
556
685
  elif args.command == "last":
557
686
  _refresh_skill_if_stale()
558
687
  last(do_clear=args.clear, requested=args.repo)
688
+ elif args.command == "list":
689
+ _refresh_skill_if_stale()
690
+ list_recent(n=args.n, requested=args.repo)
691
+ elif args.command == "show":
692
+ _refresh_skill_if_stale()
693
+ show(args.index, requested=args.repo)
559
694
  elif args.command == "nuke":
560
695
  nuke()
561
696
  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.17
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
@@ -51,6 +51,8 @@ Your app needs [ghosttrap-sdk](https://github.com/alex-rowley/ghosttrap-sdk) to
51
51
  | `ghosttrap last` | Fetch the most recent error and exit (no waiting) |
52
52
  | `ghosttrap last --clear` | Fetch the most recent error and skip everything older |
53
53
  | `ghosttrap watch` | Stream all errors continuously |
54
+ | `ghosttrap list [n]` | Print a numbered summary of the most recent `n` errors (default 10, max 50). Doesn't move the cursor. |
55
+ | `ghosttrap show <i>` | Full details for row `i` from the last `list` (1-based). Doesn't move the cursor. |
54
56
  | `ghosttrap clear` | Skip all outstanding errors |
55
57
  | `ghosttrap nuke` | Permanently delete every server-side row for the current repo (errors + token). Requires typed confirmation. |
56
58
 
@@ -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.17"
8
8
  description = "Watch for errors streaming from ghosttrap.io"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
File without changes