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.
- {ghosttrap_cli-0.3.16 → ghosttrap_cli-0.3.17}/PKG-INFO +3 -1
- {ghosttrap_cli-0.3.16 → ghosttrap_cli-0.3.17}/README.md +2 -0
- {ghosttrap_cli-0.3.16 → ghosttrap_cli-0.3.17}/ghosttrap_cli/cli.py +154 -19
- {ghosttrap_cli-0.3.16 → ghosttrap_cli-0.3.17}/ghosttrap_cli.egg-info/PKG-INFO +3 -1
- {ghosttrap_cli-0.3.16 → ghosttrap_cli-0.3.17}/pyproject.toml +1 -1
- {ghosttrap_cli-0.3.16 → ghosttrap_cli-0.3.17}/ghosttrap_cli/__init__.py +0 -0
- {ghosttrap_cli-0.3.16 → ghosttrap_cli-0.3.17}/ghosttrap_cli.egg-info/SOURCES.txt +0 -0
- {ghosttrap_cli-0.3.16 → ghosttrap_cli-0.3.17}/ghosttrap_cli.egg-info/dependency_links.txt +0 -0
- {ghosttrap_cli-0.3.16 → ghosttrap_cli-0.3.17}/ghosttrap_cli.egg-info/entry_points.txt +0 -0
- {ghosttrap_cli-0.3.16 → ghosttrap_cli-0.3.17}/ghosttrap_cli.egg-info/requires.txt +0 -0
- {ghosttrap_cli-0.3.16 → ghosttrap_cli-0.3.17}/ghosttrap_cli.egg-info/top_level.txt +0 -0
- {ghosttrap_cli-0.3.16 → ghosttrap_cli-0.3.17}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ghosttrap-cli
|
|
3
|
-
Version: 0.3.
|
|
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.
|
|
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
|
|
205
|
-
"""
|
|
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.
|
|
215
|
+
for k, entry in repos.items():
|
|
212
216
|
if f"{entry.get('owner')}/{entry.get('name')}" == requested:
|
|
213
|
-
return entry
|
|
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.
|
|
224
|
+
for k, entry in repos.items():
|
|
221
225
|
if f"{entry.get('owner')}/{entry.get('name')}" == cwd_repo:
|
|
222
|
-
return entry
|
|
223
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|