ghosttrap-cli 0.3.15__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.15
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,9 +51,13 @@ 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
 
59
+ `peek`, `watch`, `last`, and `clear` accept `--repo owner/name` to target a specific claimed repo when you're not inside its working tree (e.g. `ghosttrap peek --repo alex-rowley/ghosttrap-cli`). Otherwise they detect the repo from cwd. `nuke` is intentionally cwd-locked.
60
+
57
61
  ## How it works
58
62
 
59
63
  - **Setup** authenticates with GitHub (via the active `gh` account) to prove you have access to the repo, then saves a repo token locally. If your active `gh` account can't see the repo, setup fails with a clear message; switch with `gh auth switch` and retry.
@@ -42,9 +42,13 @@ 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
 
50
+ `peek`, `watch`, `last`, and `clear` accept `--repo owner/name` to target a specific claimed repo when you're not inside its working tree (e.g. `ghosttrap peek --repo alex-rowley/ghosttrap-cli`). Otherwise they detect the repo from cwd. `nuke` is intentionally cwd-locked.
51
+
48
52
  ## How it works
49
53
 
50
54
  - **Setup** authenticates with GitHub (via the active `gh` account) to prove you have access to the repo, then saves a repo token locally. If your active `gh` account can't see the repo, setup fails with a clear message; switch with `gh auth switch` and retry.
@@ -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.15"
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,18 +205,33 @@ def get_gh_token():
201
205
  sys.exit(1)
202
206
 
203
207
 
204
- def _get_repo_token(config):
205
- """Get the repo token for the current directory from config."""
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", {})
211
+ if not repos:
212
+ print("error: no repos configured. run 'ghosttrap setup' first.", file=sys.stderr)
213
+ sys.exit(1)
214
+ if requested:
215
+ for k, entry in repos.items():
216
+ if f"{entry.get('owner')}/{entry.get('name')}" == requested:
217
+ return k, entry
218
+ available = sorted(f"{e['owner']}/{e['name']}" for e in repos.values())
219
+ print(f"error: '{requested}' is not in your config.", file=sys.stderr)
220
+ print(f"available: {', '.join(available)}", file=sys.stderr)
221
+ sys.exit(1)
207
222
  cwd_repo = _detect_repo_from_cwd()
208
223
  if cwd_repo:
209
- for entry in repos.values():
224
+ for k, entry in repos.items():
210
225
  if f"{entry.get('owner')}/{entry.get('name')}" == cwd_repo:
211
- return entry["token"]
212
- if repos:
213
- return next(iter(repos.values()))["token"]
214
- print("error: no repos configured. run 'ghosttrap setup' first.", file=sys.stderr)
215
- sys.exit(1)
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"]
216
235
 
217
236
 
218
237
  async def _connect_and_handle(server_url, token, config, once=False):
@@ -388,10 +407,10 @@ def _advance_cursor(config, token):
388
407
  return pending
389
408
 
390
409
 
391
- def clear():
410
+ def clear(requested=None):
392
411
  _require_setup()
393
412
  config = _load_config()
394
- token = _get_repo_token(config)
413
+ token = _get_repo_token(config, requested)
395
414
  try:
396
415
  pending = _advance_cursor(config, token)
397
416
  if pending:
@@ -456,11 +475,131 @@ def nuke():
456
475
  _save_config(config)
457
476
 
458
477
 
459
- def last(do_clear=False):
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):
460
515
  _require_setup()
461
516
  config = _load_config()
462
517
  _check_cli_version(config)
463
- token = _get_repo_token(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
+
598
+ def last(do_clear=False, requested=None):
599
+ _require_setup()
600
+ config = _load_config()
601
+ _check_cli_version(config)
602
+ token = _get_repo_token(config, requested)
464
603
  server = GHOSTTRAP_SERVER.replace("wss://", "https://").replace("/stream/", "")
465
604
  url = f"{server}/last/{token}/"
466
605
  try:
@@ -476,17 +615,7 @@ def last(do_clear=False):
476
615
  print("no errors yet", file=sys.stderr)
477
616
  return
478
617
 
479
- print(json.dumps({"type": "error", "error": error}))
480
- sys.stdout.flush()
481
-
482
- print(f"\n{'='*60}", file=sys.stderr)
483
- print(f" {error.get('repo', '?')}", file=sys.stderr)
484
- print(f" {error.get('type', '?')}: {error.get('message', '')}", file=sys.stderr)
485
- frames = error.get("frames", [])
486
- if frames:
487
- f = frames[-1]
488
- print(f" at {f.get('file', '?')}:{f.get('line', '?')} in {f.get('function', '?')}", file=sys.stderr)
489
- print(f"{'='*60}", file=sys.stderr)
618
+ _print_error_details(error)
490
619
 
491
620
  if do_clear:
492
621
  try:
@@ -501,17 +630,30 @@ def main():
501
630
  sub = parser.add_subparsers(dest="command")
502
631
 
503
632
  sub.add_parser("setup", help="Claim repos and install Claude Code skill")
504
- sub.add_parser("clear", help="Skip all outstanding errors")
633
+
634
+ clear_parser = sub.add_parser("clear", help="Skip all outstanding errors")
635
+ clear_parser.add_argument("--repo", help="Target repo as owner/name (overrides cwd detection)")
505
636
 
506
637
  watch_parser = sub.add_parser("watch", help="Stream errors in real time")
507
638
  watch_parser.add_argument("--server", default=GHOSTTRAP_SERVER, help="WebSocket server URL")
639
+ watch_parser.add_argument("--repo", help="Target repo as owner/name (overrides cwd detection)")
508
640
 
509
641
  peek_parser = sub.add_parser("peek", help="Wait for the next error then exit")
510
642
  peek_parser.add_argument("--server", default=GHOSTTRAP_SERVER, help="WebSocket server URL")
511
643
  peek_parser.add_argument("--clear", action="store_true", help="Skip outstanding errors before waiting")
644
+ peek_parser.add_argument("--repo", help="Target repo as owner/name (overrides cwd detection)")
512
645
 
513
646
  last_parser = sub.add_parser("last", help="Fetch the most recent error then exit")
514
647
  last_parser.add_argument("--clear", action="store_true", help="Also skip remaining outstanding errors")
648
+ last_parser.add_argument("--repo", help="Target repo as owner/name (overrides cwd detection)")
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)")
515
657
 
516
658
  sub.add_parser("nuke", help="Permanently delete all server data for the current repo")
517
659
 
@@ -521,18 +663,18 @@ def main():
521
663
  token = get_gh_token()
522
664
  asyncio.run(setup(GHOSTTRAP_SERVER, token))
523
665
  elif args.command == "clear":
524
- clear()
666
+ clear(requested=args.repo)
525
667
  elif args.command == "watch":
526
668
  _require_setup()
527
669
  _refresh_skill_if_stale()
528
670
  config = _load_config()
529
- token = _get_repo_token(config)
671
+ token = _get_repo_token(config, args.repo)
530
672
  asyncio.run(watch(args.server, token))
531
673
  elif args.command == "peek":
532
674
  _require_setup()
533
675
  _refresh_skill_if_stale()
534
676
  config = _load_config()
535
- token = _get_repo_token(config)
677
+ token = _get_repo_token(config, args.repo)
536
678
  if args.clear:
537
679
  try:
538
680
  _advance_cursor(config, token)
@@ -542,7 +684,13 @@ def main():
542
684
  asyncio.run(peek(args.server, token))
543
685
  elif args.command == "last":
544
686
  _refresh_skill_if_stale()
545
- last(do_clear=args.clear)
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)
546
694
  elif args.command == "nuke":
547
695
  nuke()
548
696
  else:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ghosttrap-cli
3
- Version: 0.3.15
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,9 +51,13 @@ 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
 
59
+ `peek`, `watch`, `last`, and `clear` accept `--repo owner/name` to target a specific claimed repo when you're not inside its working tree (e.g. `ghosttrap peek --repo alex-rowley/ghosttrap-cli`). Otherwise they detect the repo from cwd. `nuke` is intentionally cwd-locked.
60
+
57
61
  ## How it works
58
62
 
59
63
  - **Setup** authenticates with GitHub (via the active `gh` account) to prove you have access to the repo, then saves a repo token locally. If your active `gh` account can't see the repo, setup fails with a clear message; switch with `gh auth switch` and retry.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ghosttrap-cli"
7
- version = "0.3.15"
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