ghp 0.1.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.
ghp/__init__.py ADDED
File without changes
ghp/cli.py ADDED
@@ -0,0 +1,610 @@
1
+ """Stateless GitHub activity summary for a repo.
2
+
3
+ Compact, LLM-friendly output. Caller owns the cursor via --since.
4
+
5
+ Usage:
6
+ ghp # snapshot: open issues + PRs
7
+ ghp 1h # deltas since 1 hour ago
8
+ ghp 2026-03-07T14:00:00Z
9
+ ghp --json # machine-readable output
10
+ ghp --me @clod # highlight mentions
11
+ ghp --repo owner/name # explicit repo (else auto-detect)
12
+ """
13
+
14
+ import argparse
15
+ import json as json_mod
16
+ import os
17
+ import re
18
+ import subprocess
19
+ import sys
20
+ import urllib.error
21
+ import urllib.parse
22
+ import urllib.request
23
+ from datetime import datetime, timedelta, timezone
24
+ from importlib.metadata import PackageNotFoundError, version
25
+
26
+ ISO_8601_UTC = "%Y-%m-%dT%H:%M:%SZ"
27
+ GITHUB_HANDLE_CHARS = "A-Za-z0-9-"
28
+ LAST_UPDATE_FILENAME = ".ghp-last-update-timestamp"
29
+
30
+
31
+ class ApiError(RuntimeError):
32
+ """Raised when a GitHub API request fails."""
33
+
34
+
35
+ def _utc_now():
36
+ return datetime.now(timezone.utc)
37
+
38
+
39
+ def _pkg_version():
40
+ try:
41
+ return version("ghp")
42
+ except PackageNotFoundError:
43
+ return "0.0.0"
44
+
45
+
46
+ def _format_utc(dt):
47
+ return dt.astimezone(timezone.utc).strftime(ISO_8601_UTC)
48
+
49
+
50
+ def _parse_iso8601(raw):
51
+ try:
52
+ normalized = raw.replace("Z", "+00:00")
53
+ dt = datetime.fromisoformat(normalized)
54
+ except ValueError as exc:
55
+ raise ValueError(f"invalid ISO 8601 timestamp: {raw}") from exc
56
+ if dt.tzinfo is None:
57
+ dt = dt.replace(tzinfo=timezone.utc)
58
+ return dt.astimezone(timezone.utc)
59
+
60
+
61
+ def _get_token():
62
+ """Find a GitHub token from env or gh CLI."""
63
+ for var in ("GITHUB_PAT", "GITHUB_TOKEN", "GH_TOKEN"):
64
+ tok = os.environ.get(var)
65
+ if tok:
66
+ return tok
67
+ try:
68
+ result = subprocess.run(
69
+ ["gh", "auth", "token"], capture_output=True, text=True, timeout=5
70
+ )
71
+ if result.returncode == 0 and result.stdout.strip():
72
+ return result.stdout.strip()
73
+ except (FileNotFoundError, subprocess.TimeoutExpired):
74
+ pass
75
+ return None
76
+
77
+
78
+ def _detect_repo():
79
+ """Detect owner/repo from git remote."""
80
+ try:
81
+ result = subprocess.run(
82
+ ["git", "remote", "get-url", "origin"],
83
+ capture_output=True,
84
+ text=True,
85
+ timeout=5,
86
+ )
87
+ url = result.stdout.strip()
88
+ except (FileNotFoundError, subprocess.TimeoutExpired):
89
+ return None
90
+ match = re.search(r"github\.com[:/](.+?)(?:\.git)?$", url)
91
+ if match:
92
+ return match.group(1)
93
+ return None
94
+
95
+
96
+ def _api(path, token, params=None):
97
+ """GET JSON from GitHub REST API."""
98
+ url = f"https://api.github.com/{path.lstrip('/')}"
99
+ if params:
100
+ url = f"{url}?{urllib.parse.urlencode(params, doseq=True)}"
101
+
102
+ headers = {
103
+ "Accept": "application/vnd.github+json",
104
+ "X-GitHub-Api-Version": "2022-11-28",
105
+ }
106
+ if token:
107
+ headers["Authorization"] = f"Bearer {token}"
108
+
109
+ req = urllib.request.Request(url, headers=headers)
110
+ try:
111
+ with urllib.request.urlopen(req, timeout=15) as resp:
112
+ return json_mod.loads(resp.read())
113
+ except urllib.error.HTTPError as exc:
114
+ detail = ""
115
+ try:
116
+ payload = json_mod.loads(exc.read().decode())
117
+ detail = payload.get("message", "")
118
+ except Exception:
119
+ detail = ""
120
+ suffix = f" ({detail})" if detail else ""
121
+ raise ApiError(f"API error: {exc.code} {exc.reason} for {url}{suffix}") from exc
122
+ except urllib.error.URLError as exc:
123
+ raise ApiError(f"API error: {exc.reason} for {url}") from exc
124
+
125
+
126
+ def _resolve_since(raw):
127
+ """Parse --since value into a canonical UTC ISO 8601 string."""
128
+ if re.match(r"^\d{4}-\d{2}-\d{2}T", raw):
129
+ return _format_utc(_parse_iso8601(raw))
130
+
131
+ match = re.match(r"^(\d+)([mhdw])$", raw)
132
+ if not match:
133
+ raise ValueError(
134
+ f"invalid --since: {raw} (use 30m, 2h, 1d, 1w, or ISO 8601)"
135
+ )
136
+
137
+ num, unit = int(match.group(1)), match.group(2)
138
+ delta = {
139
+ "m": timedelta(minutes=num),
140
+ "h": timedelta(hours=num),
141
+ "d": timedelta(days=num),
142
+ "w": timedelta(weeks=num),
143
+ }[unit]
144
+ return _format_utc(_utc_now() - delta)
145
+
146
+
147
+ def _fmt_issue(issue):
148
+ parts = [
149
+ f'#{issue["number"]}',
150
+ issue["state"],
151
+ f'@{issue["user"]["login"]}',
152
+ issue["title"],
153
+ ]
154
+ labels = ",".join(label["name"] for label in issue.get("labels", []))
155
+ if labels:
156
+ parts.append(f"l:{labels}")
157
+ if issue.get("comments"):
158
+ parts.append(f'c:{issue["comments"]}')
159
+ return " ".join(parts)
160
+
161
+
162
+ def _fmt_pr(pr):
163
+ status = pr["state"]
164
+ if pr.get("draft"):
165
+ status = f"{status},draft"
166
+
167
+ parts = [
168
+ f'#{pr["number"]}',
169
+ status,
170
+ f'@{pr["user"]["login"]}',
171
+ f'{pr["head"]["ref"]}->{pr["base"]["ref"]}',
172
+ pr["title"],
173
+ ]
174
+ if isinstance(pr.get("comments"), int) and pr["comments"] > 0:
175
+ parts.append(f'c:{pr["comments"]}')
176
+ if isinstance(pr.get("review_comments"), int) and pr["review_comments"] > 0:
177
+ parts.append(f'rc:{pr["review_comments"]}')
178
+ return " ".join(parts)
179
+
180
+
181
+ def _comment_number(comment):
182
+ url = comment.get("pull_request_url") or comment.get("issue_url") or ""
183
+ return url.rstrip("/").split("/")[-1] if url else "?"
184
+
185
+
186
+ def _comment_timestamp(comment):
187
+ return comment.get("updated_at") or comment.get("created_at") or ""
188
+
189
+
190
+ def _trim_comment_body(body, limit):
191
+ flattened = re.sub(r"\s+", " ", (body or "").replace("\r", " ")).strip()
192
+ if not flattened:
193
+ return "[no body]"
194
+ if len(flattened) > limit:
195
+ return flattened[:limit] + "..."
196
+ return flattened
197
+
198
+
199
+ def _fmt_comment(comment):
200
+ kind = "review" if comment.get("comment_type") == "review" else "comment"
201
+ return (
202
+ f"#{_comment_number(comment)} {kind} @{comment['user']['login']} "
203
+ f"{_comment_timestamp(comment)}: {_trim_comment_body(comment.get('body'), 120)}"
204
+ )
205
+
206
+
207
+ def _print_section(name, items, formatter):
208
+ if not items:
209
+ return
210
+ print(f"{name} {len(items)}")
211
+ for item in items:
212
+ print(formatter(item))
213
+
214
+
215
+ def _fetch_issues(repo, token, limit, cutoff):
216
+ params = {
217
+ "state": "all" if cutoff else "open",
218
+ "sort": "updated",
219
+ "direction": "desc",
220
+ }
221
+ if cutoff:
222
+ params["since"] = cutoff
223
+
224
+ per_page = min(max(limit * 2, 30), 100)
225
+ issues = []
226
+ page = 1
227
+
228
+ while len(issues) < limit:
229
+ batch = _api(
230
+ f"repos/{repo}/issues",
231
+ token,
232
+ {**params, "per_page": per_page, "page": page},
233
+ )
234
+ if not isinstance(batch, list):
235
+ raise ApiError("Unexpected issues payload from GitHub API")
236
+ if not batch:
237
+ break
238
+
239
+ for item in batch:
240
+ if "pull_request" in item:
241
+ continue
242
+ issues.append(item)
243
+ if len(issues) >= limit:
244
+ break
245
+
246
+ if len(batch) < per_page:
247
+ break
248
+ page += 1
249
+
250
+ return issues
251
+
252
+
253
+ def _fetch_prs(repo, token, limit, cutoff):
254
+ params = {
255
+ "state": "all" if cutoff else "open",
256
+ "sort": "updated",
257
+ "direction": "desc",
258
+ }
259
+ per_page = min(max(limit, 30), 100)
260
+ cutoff_dt = _parse_iso8601(cutoff) if cutoff else None
261
+ prs = []
262
+ page = 1
263
+
264
+ while len(prs) < limit:
265
+ batch = _api(
266
+ f"repos/{repo}/pulls",
267
+ token,
268
+ {**params, "per_page": per_page, "page": page},
269
+ )
270
+ if not isinstance(batch, list):
271
+ raise ApiError("Unexpected pull request payload from GitHub API")
272
+ if not batch:
273
+ break
274
+
275
+ for pr in batch:
276
+ if cutoff_dt and _parse_iso8601(pr["updated_at"]) < cutoff_dt:
277
+ return prs
278
+ prs.append(pr)
279
+ if len(prs) >= limit:
280
+ return prs
281
+
282
+ if len(batch) < per_page:
283
+ break
284
+ page += 1
285
+
286
+ return prs
287
+
288
+
289
+ def _fetch_comment_endpoint(repo, token, path, comment_type, limit, cutoff):
290
+ per_page = min(max(limit, 30), 100)
291
+ comments = []
292
+ page = 1
293
+
294
+ while len(comments) < limit:
295
+ batch = _api(
296
+ f"repos/{repo}/{path}",
297
+ token,
298
+ {
299
+ "since": cutoff,
300
+ "sort": "updated",
301
+ "direction": "desc",
302
+ "per_page": per_page,
303
+ "page": page,
304
+ },
305
+ )
306
+ if not isinstance(batch, list):
307
+ raise ApiError("Unexpected comments payload from GitHub API")
308
+ if not batch:
309
+ break
310
+
311
+ for comment in batch:
312
+ typed_comment = dict(comment)
313
+ typed_comment["comment_type"] = comment_type
314
+ comments.append(typed_comment)
315
+ if len(comments) >= limit:
316
+ break
317
+
318
+ if len(batch) < per_page:
319
+ break
320
+ page += 1
321
+
322
+ return comments
323
+
324
+
325
+ def _comment_sort_key(comment):
326
+ updated_at = _parse_iso8601(_comment_timestamp(comment))
327
+ created_at = _parse_iso8601(comment.get("created_at") or _comment_timestamp(comment))
328
+ return (updated_at, created_at, comment.get("id", 0))
329
+
330
+
331
+ def _merge_comments(comment_groups, limit):
332
+ merged = []
333
+ for group in comment_groups:
334
+ merged.extend(group)
335
+ merged.sort(key=_comment_sort_key, reverse=True)
336
+ return merged[:limit]
337
+
338
+
339
+ def _fetch_comments(repo, token, limit, cutoff):
340
+ if not cutoff:
341
+ return []
342
+
343
+ issue_comments = _fetch_comment_endpoint(
344
+ repo, token, "issues/comments", "issue", limit, cutoff
345
+ )
346
+ review_comments = _fetch_comment_endpoint(
347
+ repo, token, "pulls/comments", "review", limit, cutoff
348
+ )
349
+ return _merge_comments([issue_comments, review_comments], limit)
350
+
351
+
352
+ def _fetch_commits(repo, token, limit, cutoff):
353
+ if not cutoff:
354
+ return []
355
+
356
+ per_page = min(max(limit, 30), 100)
357
+ commits = []
358
+ page = 1
359
+
360
+ while len(commits) < limit:
361
+ batch = _api(
362
+ f"repos/{repo}/commits",
363
+ token,
364
+ {
365
+ "since": cutoff,
366
+ "per_page": per_page,
367
+ "page": page,
368
+ },
369
+ )
370
+ if not isinstance(batch, list):
371
+ raise ApiError("Unexpected commits payload from GitHub API")
372
+ if not batch:
373
+ break
374
+
375
+ commits.extend(batch[: max(limit - len(commits), 0)])
376
+
377
+ if len(batch) < per_page:
378
+ break
379
+ page += 1
380
+
381
+ return commits[:limit]
382
+
383
+
384
+ def _mention_pattern(handle):
385
+ normalized = handle.lstrip("@").strip()
386
+ if not normalized:
387
+ return None
388
+ return re.compile(
389
+ rf"(?<![{GITHUB_HANDLE_CHARS}])@{re.escape(normalized)}(?![{GITHUB_HANDLE_CHARS}])",
390
+ re.IGNORECASE,
391
+ )
392
+
393
+
394
+ def _emit_error(message, json_output, repo, timestamp, cutoff):
395
+ if json_output:
396
+ print(
397
+ json_mod.dumps(
398
+ {
399
+ "repo": repo,
400
+ "timestamp": timestamp,
401
+ "since": cutoff,
402
+ "error": message,
403
+ },
404
+ separators=(",", ":"),
405
+ )
406
+ )
407
+ return
408
+ print(f"Error: {message}", file=sys.stderr)
409
+
410
+
411
+ def _timestamp_file_path():
412
+ return os.path.join(os.getcwd(), LAST_UPDATE_FILENAME)
413
+
414
+
415
+ def _load_last_update_timestamp():
416
+ path = _timestamp_file_path()
417
+ if not os.path.exists(path):
418
+ return None
419
+ with open(path, "r", encoding="utf-8") as fh:
420
+ raw = fh.read().strip()
421
+ if not raw:
422
+ return None
423
+ return _resolve_since(raw)
424
+
425
+
426
+ def _save_last_update_timestamp(timestamp):
427
+ path = _timestamp_file_path()
428
+ with open(path, "w", encoding="utf-8") as fh:
429
+ fh.write(timestamp + "\n")
430
+
431
+
432
+ def _fmt_commit(commit):
433
+ sha = (commit.get("sha") or "")[:7] or "?"
434
+ message = (commit.get("commit") or {}).get("message") or ""
435
+ summary = message.splitlines()[0].strip() if message else "[no message]"
436
+ author = (
437
+ ((commit.get("author") or {}).get("login"))
438
+ or ((commit.get("commit") or {}).get("author") or {}).get("name")
439
+ or "unknown"
440
+ )
441
+ date = ((commit.get("commit") or {}).get("author") or {}).get("date") or ""
442
+ return f"{sha} @{author} {date} {summary}"
443
+
444
+
445
+ def main():
446
+ parser = argparse.ArgumentParser(
447
+ prog="ghp",
448
+ description="Stateless GitHub activity summary — compact, LLM-friendly output",
449
+ )
450
+ parser.add_argument(
451
+ "since_pos",
452
+ nargs="?",
453
+ metavar="TIME",
454
+ help=argparse.SUPPRESS,
455
+ )
456
+ parser.add_argument(
457
+ "--since",
458
+ metavar="TIME",
459
+ help="Show deltas since TIME (ISO 8601 or relative: 30m, 2h, 1d, 1w)",
460
+ )
461
+ parser.add_argument("--json", action="store_true", help="Output as JSON")
462
+ parser.add_argument(
463
+ "--version", action="version", version=f"%(prog)s {_pkg_version()}"
464
+ )
465
+ parser.add_argument(
466
+ "--me", metavar="HANDLE", help="Highlight mentions of this handle (e.g. @clod)"
467
+ )
468
+ parser.add_argument(
469
+ "--repo",
470
+ metavar="OWNER/REPO",
471
+ help="GitHub repo (default: auto-detect from git remote)",
472
+ )
473
+ parser.add_argument(
474
+ "--limit", type=int, default=30, help="Max items per category (default: 30)"
475
+ )
476
+ args = parser.parse_args()
477
+ if args.since and args.since_pos:
478
+ parser.error("use TIME or --since, not both")
479
+
480
+ now = _format_utc(_utc_now())
481
+ repo = args.repo or _detect_repo()
482
+ cutoff = None
483
+
484
+ if args.limit < 1:
485
+ _emit_error("--limit must be >= 1", args.json, repo, now, cutoff)
486
+ return 1
487
+
488
+ if not repo:
489
+ _emit_error(
490
+ "could not detect repo. Use --repo owner/name or run from a git checkout.",
491
+ args.json,
492
+ repo,
493
+ now,
494
+ cutoff,
495
+ )
496
+ return 1
497
+
498
+ try:
499
+ raw_since = args.since or args.since_pos
500
+ cutoff = _resolve_since(raw_since) if raw_since else _load_last_update_timestamp()
501
+ except ValueError as exc:
502
+ _emit_error(str(exc), args.json, repo, now, cutoff)
503
+ return 1
504
+
505
+ token = _get_token()
506
+
507
+ try:
508
+ issues = _fetch_issues(repo, token, args.limit, cutoff)
509
+ prs = _fetch_prs(repo, token, args.limit, cutoff)
510
+ comments = _fetch_comments(repo, token, args.limit, cutoff)
511
+ commits = _fetch_commits(repo, token, args.limit, cutoff)
512
+ except ApiError as exc:
513
+ _emit_error(str(exc), args.json, repo, now, cutoff)
514
+ return 1
515
+
516
+ if args.json:
517
+ out = {
518
+ "repo": repo,
519
+ "timestamp": now,
520
+ "since": cutoff,
521
+ "issues": [
522
+ {
523
+ "number": issue["number"],
524
+ "title": issue["title"],
525
+ "state": issue["state"],
526
+ "user": issue["user"]["login"],
527
+ "labels": [label["name"] for label in issue.get("labels", [])],
528
+ "updated_at": issue["updated_at"],
529
+ "created_at": issue["created_at"],
530
+ "comments": issue.get("comments", 0),
531
+ }
532
+ for issue in issues
533
+ ],
534
+ "pull_requests": [
535
+ {
536
+ "number": pr["number"],
537
+ "title": pr["title"],
538
+ "state": pr["state"],
539
+ "user": pr["user"]["login"],
540
+ "base": pr["base"]["ref"],
541
+ "head": pr["head"]["ref"],
542
+ "draft": pr.get("draft", False),
543
+ "updated_at": pr["updated_at"],
544
+ "created_at": pr["created_at"],
545
+ "review_comments": pr.get("review_comments"),
546
+ "comments": pr.get("comments"),
547
+ }
548
+ for pr in prs
549
+ ],
550
+ "recent_comments": [
551
+ {
552
+ "number": _comment_number(comment),
553
+ "comment_type": comment.get("comment_type", "issue"),
554
+ "user": comment["user"]["login"],
555
+ "created_at": comment.get("created_at"),
556
+ "updated_at": comment.get("updated_at"),
557
+ "body": _trim_comment_body(comment.get("body"), 200),
558
+ }
559
+ for comment in comments
560
+ ],
561
+ "commits": [
562
+ {
563
+ "sha": commit.get("sha"),
564
+ "message": ((commit.get("commit") or {}).get("message") or "").splitlines()[0],
565
+ "author": (
566
+ ((commit.get("author") or {}).get("login"))
567
+ or ((commit.get("commit") or {}).get("author") or {}).get("name")
568
+ ),
569
+ "date": ((commit.get("commit") or {}).get("author") or {}).get("date"),
570
+ "url": commit.get("html_url"),
571
+ }
572
+ for commit in commits
573
+ ],
574
+ }
575
+ print(json_mod.dumps(out, separators=(",", ":")))
576
+ _save_last_update_timestamp(now)
577
+ return 0
578
+
579
+ head = f"{repo} {now}"
580
+ if cutoff:
581
+ head = f"{head} since={cutoff}"
582
+ print(head)
583
+
584
+ _print_section("iss", issues, _fmt_issue)
585
+ _print_section("pr", prs, _fmt_pr)
586
+ if cutoff:
587
+ _print_section("com", comments, _fmt_comment)
588
+ _print_section("git", commits, _fmt_commit)
589
+
590
+ mention_re = _mention_pattern(args.me) if args.me else None
591
+ if mention_re and comments:
592
+ hits = [
593
+ comment for comment in comments if mention_re.search(comment.get("body") or "")
594
+ ]
595
+ if hits:
596
+ handle = args.me.lstrip("@")
597
+ print(f"@{handle} {len(hits)}")
598
+ for comment in hits:
599
+ print(_fmt_comment(comment))
600
+
601
+ if not issues and not prs and not comments and not commits:
602
+ print("none")
603
+
604
+ _save_last_update_timestamp(now)
605
+
606
+ return 0
607
+
608
+
609
+ if __name__ == "__main__":
610
+ raise SystemExit(main())
@@ -0,0 +1,63 @@
1
+ Metadata-Version: 2.4
2
+ Name: ghp
3
+ Version: 0.1.0
4
+ Summary: Stateless GitHub activity summary — compact, LLM-friendly output
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+
9
+ # ghp
10
+
11
+ Stateless GitHub activity summary. Compact, LLM-friendly output.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ uv tool install ghp
17
+ # or
18
+ uvx ghp
19
+ ```
20
+
21
+ ## Dev
22
+
23
+ ```bash
24
+ uv sync --group dev
25
+ uv run ghp --version
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ ```bash
31
+ ghp # open issues + PRs snapshot
32
+ ghp 1h # deltas since 1 hour ago
33
+ ghp 2026-03-07T14:00:00Z # deltas since timestamp
34
+ ghp --json # machine-readable output
35
+ ghp --me @clod # highlight mentions
36
+ ghp --repo owner/name # explicit repo
37
+ # default delta cursor comes from .ghp-last-update-timestamp if present
38
+ ```
39
+
40
+ ## Behavior
41
+
42
+ - Cursor defaults to `.ghp-last-update-timestamp` in the current working directory when `--since` is omitted.
43
+ - Successful runs autosave the current timestamp back to `.ghp-last-update-timestamp`.
44
+ - `--since` accepts relative shorthands (`30m`, `2h`, `1d`, `1w`) and normalizes timestamps to canonical UTC.
45
+ - Snapshot mode returns open issues and open PRs.
46
+ - Delta mode returns issues, PRs, issue comments, PR review comments, and recent commits since the cutoff.
47
+ - API or auth failures exit non-zero instead of silently pretending there was no activity.
48
+ - `--json` prints a machine-readable payload; on failure it emits a JSON object with an `error` field and exits non-zero.
49
+
50
+ ## Auth
51
+
52
+ Looks for tokens in this order:
53
+ 1. `$GITHUB_PAT`
54
+ 2. `$GITHUB_TOKEN`
55
+ 3. `$GH_TOKEN`
56
+ 4. `gh auth token` (gh CLI)
57
+
58
+ ## Test
59
+
60
+ ```bash
61
+ uv run python -m unittest discover -s tests
62
+ scripts/test-install.sh
63
+ ```
@@ -0,0 +1,6 @@
1
+ ghp/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ ghp/cli.py,sha256=ijSe0CyGjFuY_M6eLAUp6pWZiFEK-jrPWhJ69TpqYbA,17969
3
+ ghp-0.1.0.dist-info/METADATA,sha256=VPQ7a7GhrMWfIWO3ee8FYshNo-eX7jgisEjSq8BYvgU,1771
4
+ ghp-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
5
+ ghp-0.1.0.dist-info/entry_points.txt,sha256=JLVVMfycLFj9VnmPgtFqSKNjwuGgXgaN57NQSsDrtT8,37
6
+ ghp-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ghp = ghp.cli:main