github-agent-cli 1.2.1__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.
ghagent.py ADDED
@@ -0,0 +1,511 @@
1
+ #!/usr/bin/env python3
2
+ """ghagent — one command per documentation job, so an agent stops reading files by hand.
3
+
4
+ ghagent.py audit [path] [--repo owner/name] [--json] [--http] [--min-score 80]
5
+ ghagent.py doc [paths...] [--root DIR] [--json] [--ignore RULE,...] [--no-privacy]
6
+ ghagent.py wiki build|verify|publish|init ...
7
+ ghagent.py changelog check|release|suggest|notes ...
8
+ ghagent.py meta suggest|apply|check ...
9
+ ghagent.py privacy [path]
10
+ ghagent.py install|verify-install|uninstall --target DIR [--write]
11
+ ghagent.py snippet
12
+ ghagent.py report [path] [--out FILE]
13
+ ghagent.py selftest
14
+
15
+ Exit codes: 0 clean, 1 findings at error level, 2 usage or environment problem.
16
+ Standard library only; no network except `audit --repo`, `meta`, and `wiki publish`.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import json
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ HERE = Path(__file__).resolve().parent
27
+ if str(HERE) not in sys.path:
28
+ sys.path.insert(0, str(HERE))
29
+
30
+ from ghalib import assets as assets_module # noqa: E402
31
+ from ghalib import audit as audit_module # noqa: E402
32
+ from ghalib import changelog as changelog_module # noqa: E402
33
+ from ghalib import config as cfg # noqa: E402
34
+ from ghalib import install as install_module # noqa: E402
35
+ from ghalib import mdcheck # noqa: E402
36
+ from ghalib import meta as meta_module # noqa: E402
37
+ from ghalib import report as report_module # noqa: E402
38
+ from ghalib import wiki as wiki_module # noqa: E402
39
+ from ghalib.util import ( # noqa: E402
40
+ EXIT_FAIL,
41
+ EXIT_OK,
42
+ EXIT_USAGE,
43
+ PACK_NAME,
44
+ PACK_VERSION,
45
+ Report,
46
+ emit,
47
+ fail,
48
+ force_utf8_stdio,
49
+ read_text,
50
+ rel,
51
+ write_text,
52
+ )
53
+
54
+ # The source tree is the pack root: it holds tools/ghagent.py, tests/, skills/, reference/,
55
+ # docs/ and the wiki sources. An installed wheel carries the CLI and the library only, so a
56
+ # command that copies or verifies the pack has nothing to work with there and must say so
57
+ # instead of writing an empty manifest or printing a PASS it did not measure.
58
+ PACK_ROOT = HERE.parent
59
+ SOURCE_MARKER = PACK_ROOT / "tools" / "ghagent.py"
60
+ CLONE_URL = "https://github.com/paulmann/github-agent"
61
+
62
+
63
+ def _needs_source_tree(command: str) -> int | None:
64
+ """None when the pack root is present; exit 2 with an honest message when it is not."""
65
+ if SOURCE_MARKER.exists():
66
+ return None
67
+ print(f"ghagent: `{command}` needs the source tree: this installed copy has no "
68
+ f"tools/ghagent.py. Clone {CLONE_URL} and run "
69
+ f"`python tools/ghagent.py {command}` from the clone.", file=sys.stderr)
70
+ return EXIT_USAGE
71
+
72
+
73
+ def _add_json(parser: argparse.ArgumentParser) -> None:
74
+ parser.add_argument("--json", action="store_true", help="machine-readable output")
75
+
76
+
77
+ def _add_ignore(parser: argparse.ArgumentParser) -> None:
78
+ parser.add_argument("--ignore", default="", help="comma-separated rule ids to skip")
79
+
80
+
81
+ def cmd_assets(args: argparse.Namespace) -> int:
82
+ root = Path(args.root)
83
+ if args.action == "check":
84
+ report = assets_module.check(root)
85
+ return emit(report, args.json)
86
+ if args.action == "suggest":
87
+ report = assets_module.suggest(root)
88
+ return emit(report, args.json)
89
+ if args.action == "render":
90
+ width, _, height = (args.size or "1280x640").partition("x")
91
+ try:
92
+ spec = assets_module.spec_from_repository(
93
+ root,
94
+ title=args.title or "",
95
+ tagline=args.tagline or "",
96
+ command=args.command or "",
97
+ chips=[chip.strip() for chip in args.chips.split(",") if chip.strip()] or None,
98
+ )
99
+ spec.width, spec.height = int(width), int(height)
100
+ except ValueError:
101
+ return fail("--size must look like 1280x640")
102
+ report = assets_module.write_assets(root, out_dir=args.out_dir, spec=spec, write=args.write)
103
+ return emit(report, args.json)
104
+ return fail(f"unknown assets action: {args.action}")
105
+
106
+
107
+ def cmd_audit(args: argparse.Namespace) -> int:
108
+ if args.repo:
109
+ config = cfg.load(Path(args.path))
110
+ result = audit_module.audit_remote(args.repo, config=config, check_http=args.http)
111
+ if args.json:
112
+ print(json.dumps(result.report.as_dict(), ensure_ascii=False, indent=2))
113
+ else:
114
+ percentage, scored, skipped = result.score()
115
+ print(audit_module.render_markdown(result))
116
+ print()
117
+ print(f"score: {percentage}% ({scored} scored, {skipped} skipped)")
118
+ if args.min_score and result.score()[0] < args.min_score:
119
+ print(f"ghagent: score {result.score()[0]}% is below --min-score {args.min_score}", file=sys.stderr)
120
+ return EXIT_FAIL
121
+ return audit_module.exit_code_for(result)
122
+
123
+ root = Path(args.path)
124
+ config = cfg.load(root)
125
+ result = audit_module.audit_local(root, config=config)
126
+ percentage, scored, skipped = result.score()
127
+ if args.json:
128
+ print(json.dumps(result.report.as_dict(), ensure_ascii=False, indent=2))
129
+ else:
130
+ print(audit_module.render_markdown(result))
131
+ print()
132
+ print(f"score: {percentage}% ({scored} scored, {skipped} skipped)")
133
+ if args.min_score and percentage < args.min_score:
134
+ print(f"ghagent: score {percentage}% is below --min-score {args.min_score}", file=sys.stderr)
135
+ return EXIT_FAIL
136
+ return audit_module.exit_code_for(result)
137
+
138
+
139
+ def cmd_doc(args: argparse.Namespace) -> int:
140
+ root = Path(args.root)
141
+ options = mdcheck.DocCheckOptions(
142
+ ignore={item.strip() for item in args.ignore.split(",") if item.strip()},
143
+ privacy=not args.no_privacy,
144
+ toc=not args.no_toc,
145
+ )
146
+ report = Report(title="ghagent doc", root=str(root))
147
+ checker = mdcheck.DocChecker(root, report, options)
148
+ # Wiki sources use wiki-namespace links (page names without .md); they are checked by
149
+ # `wiki build`/`wiki verify`, not as repository links. Opt in with --include-wiki.
150
+ skip_dirs = {".git", "node_modules", "vendor", ".ghagent", ".venv", "__pycache__"}
151
+ if not args.include_wiki:
152
+ skip_dirs.add("wiki")
153
+ targets = [Path(item) for item in args.paths] if args.paths else sorted(
154
+ path for path in root.rglob("*.md")
155
+ if not (set(path.parts) & skip_dirs)
156
+ )
157
+ if not targets:
158
+ return fail("no markdown files found to check")
159
+ for path in targets:
160
+ checker.check_file(path)
161
+ checker._report_coverage()
162
+ return emit(report, args.json)
163
+
164
+
165
+ def cmd_privacy(args: argparse.Namespace) -> int:
166
+ root = Path(args.root)
167
+ options = mdcheck.DocCheckOptions(privacy=True, toc=False, max_line_rule=False)
168
+ options.ignore = {"doc.bom", "doc.crlf", "doc.final-newline", "doc.tab-indent", "md.h1",
169
+ "md.heading-skip", "md.fence", "md.table", "md.alert", "md.image-alt",
170
+ "md.hard-break", "doc.line-length", "md.link-local", "md.toc", "md.link-anchor",
171
+ "agents.md-length"}
172
+ report = Report(title="ghagent privacy", root=str(root))
173
+ checker = mdcheck.DocChecker(root, report, options)
174
+ for path in mdcheck.iter_text_files(root):
175
+ checker.check_file(path)
176
+ checker._report_coverage()
177
+ return emit(report, args.json)
178
+
179
+
180
+ def cmd_wiki(args: argparse.Namespace) -> int:
181
+ root = Path(args.root)
182
+ config = cfg.load(root)
183
+ if args.action == "init":
184
+ report = wiki_module.scaffold(root, slug=args.repo or config.owner_repo, title=args.title,
185
+ wiki_dir=args.wiki_dir, from_docs=not args.no_docs)
186
+ return emit(report, args.json)
187
+ if args.action == "build":
188
+ report = wiki_module.build(root, wiki_dir=args.wiki_dir, manifest_path=args.manifest,
189
+ out_dir=Path(args.out) if args.out else None)
190
+ if not args.json and report.data.get("out"):
191
+ print(f"built {report.data.get('pages')} page(s) into {report.data['out']}")
192
+ return emit(report, args.json)
193
+ if args.action == "verify":
194
+ out = Path(args.out) if args.out else root / ".ghagent" / "wiki"
195
+ report = wiki_module.verify(out)
196
+ return emit(report, args.json)
197
+ if args.action == "publish":
198
+ slug = args.repo or config.owner_repo
199
+ if not slug:
200
+ return fail("publish needs --repo owner/name or repo in the wiki manifest")
201
+ build_dir = Path(args.out) if args.out else root / ".ghagent" / "wiki"
202
+ if not build_dir.exists():
203
+ return fail(f"no built wiki at {build_dir}; run `ghagent wiki build` first")
204
+ verify_report = wiki_module.verify(build_dir)
205
+ if verify_report.errors and not args.force:
206
+ print(verify_report.render())
207
+ print("ghagent: refusing to publish a wiki that fails verification (pass --force only if you accept broken links)",
208
+ file=sys.stderr)
209
+ return EXIT_FAIL
210
+ report = wiki_module.publish(
211
+ slug, build_dir, push=args.push, prune=args.prune, message=args.message or "",
212
+ author_name=args.author_name or "", author_email=args.author_email or "",
213
+ work_dir=Path(args.workdir) if args.workdir else None,
214
+ remote=args.remote_url or "",
215
+ )
216
+ return emit(report, args.json)
217
+ return fail(f"unknown wiki action: {args.action}")
218
+
219
+
220
+ def cmd_changelog(args: argparse.Namespace) -> int:
221
+ root = Path(args.root)
222
+ config = cfg.load(root)
223
+ path = Path(args.file) if args.file else root / config.changelog
224
+ slug = args.repo or config.owner_repo
225
+ if args.action == "check":
226
+ report = changelog_module.check(path, slug=slug)
227
+ return emit(report, args.json)
228
+ if args.action == "release":
229
+ report = changelog_module.release(path, args.version, date=args.date or "", write=args.write,
230
+ slug=slug)
231
+ return emit(report, args.json)
232
+ if args.action == "suggest":
233
+ report = changelog_module.suggest(root, args.since, include_noise=args.include_noise)
234
+ if not args.json and report.data.get("markdown"):
235
+ print(report.render())
236
+ print()
237
+ print(report.data["markdown"])
238
+ return report.exit_code()
239
+ return emit(report, args.json)
240
+ if args.action == "notes":
241
+ report = changelog_module.notes(path, args.version)
242
+ if not args.json and report.data.get("body"):
243
+ print(report.data["body"])
244
+ return report.exit_code()
245
+ return emit(report, args.json)
246
+ return fail(f"unknown changelog action: {args.action}")
247
+
248
+
249
+ def cmd_meta(args: argparse.Namespace) -> int:
250
+ root = Path(args.root)
251
+ config = cfg.load(root)
252
+ if args.action == "suggest":
253
+ facts = meta_module.derive(root)
254
+ topics = meta_module.suggest_topics(facts)
255
+ report = Report(title="ghagent meta suggest", root=str(root))
256
+ for level, rule, message in meta_module.validate_description(facts.get("summary", "")):
257
+ report.add(level, rule, message)
258
+ for level, rule, message in meta_module.check_topics(topics, minimum=config.topic_min,
259
+ maximum=config.topic_max):
260
+ report.add(level, rule, message)
261
+ report.data.update({"facts": facts, "topics": topics,
262
+ "draft_description": _draft_description(facts)})
263
+ if args.json:
264
+ print(json.dumps(report.as_dict(), ensure_ascii=False, indent=2))
265
+ else:
266
+ print(report.render())
267
+ print()
268
+ print("facts:")
269
+ print(json.dumps(facts, ensure_ascii=False, indent=2))
270
+ print("candidate topics: " + ", ".join(topics))
271
+ print("draft description: " + _draft_description(facts))
272
+ return report.exit_code()
273
+ if args.action == "check":
274
+ report = Report(title="ghagent meta check")
275
+ for level, rule, message in meta_module.validate_description(args.description):
276
+ report.add(level, rule, message)
277
+ topics = [item.strip() for item in args.topics.split(",") if item.strip()]
278
+ for level, rule, message in meta_module.check_topics(topics, minimum=config.topic_min,
279
+ maximum=config.topic_max):
280
+ report.add(level, rule, message)
281
+ return emit(report, args.json)
282
+ if args.action == "apply":
283
+ slug = args.repo or config.owner_repo
284
+ if not slug:
285
+ return fail("meta apply needs --repo owner/name or owner_repo in .ghagent.json")
286
+ topics = [item.strip() for item in args.topics.split(",") if item.strip()] if args.topics else None
287
+ report = meta_module.apply_metadata(
288
+ slug,
289
+ description=args.description,
290
+ topics=topics,
291
+ homepage=args.homepage,
292
+ merge_topics=not args.replace_topics,
293
+ write=args.write,
294
+ )
295
+ return emit(report, args.json)
296
+ return fail(f"unknown meta action: {args.action}")
297
+
298
+
299
+ def _draft_description(facts: dict) -> str:
300
+ title = facts.get("title") or "Project"
301
+ languages = ", ".join(language for language, _count in facts.get("languages", [])[:3])
302
+ summary = facts.get("summary", "").strip()
303
+ runtime = f" {languages.capitalize()}." if languages else ""
304
+ if summary:
305
+ return f"{title}: {summary[:120].rstrip('.')}.{runtime}"
306
+ return f"{title}:{runtime} <add one capability a reader can check>"
307
+
308
+
309
+ def cmd_install(args: argparse.Namespace) -> int:
310
+ blocked = _needs_source_tree("install")
311
+ if blocked is not None:
312
+ return blocked
313
+ target, error = install_module.resolve_target(args.profile, args.target)
314
+ if target is None:
315
+ return fail(error)
316
+ report = install_module.install(PACK_ROOT, target, write=args.write)
317
+ code = emit(report, args.json)
318
+ if args.write and not args.json:
319
+ print()
320
+ print(install_module.snippet(target))
321
+ return code
322
+
323
+
324
+ def cmd_verify_install(args: argparse.Namespace) -> int:
325
+ blocked = _needs_source_tree("verify-install")
326
+ if blocked is not None:
327
+ return blocked
328
+ target, error = install_module.resolve_target(args.profile, args.target)
329
+ if target is None:
330
+ return fail(error)
331
+ report = install_module.verify(target)
332
+ return emit(report, args.json)
333
+
334
+
335
+ def cmd_uninstall(args: argparse.Namespace) -> int:
336
+ blocked = _needs_source_tree("uninstall")
337
+ if blocked is not None:
338
+ return blocked
339
+ target, error = install_module.resolve_target(args.profile, args.target)
340
+ if target is None:
341
+ return fail(error)
342
+ report = install_module.remove(target, write=args.write)
343
+ return emit(report, args.json)
344
+
345
+
346
+ def cmd_snippet(args: argparse.Namespace) -> int:
347
+ blocked = _needs_source_tree("snippet")
348
+ if blocked is not None:
349
+ return blocked
350
+ target, error = install_module.resolve_target(args.profile, args.target)
351
+ print(install_module.snippet(target))
352
+ return EXIT_OK
353
+
354
+
355
+ def cmd_report(args: argparse.Namespace) -> int:
356
+ root = Path(args.root)
357
+ text, code = report_module.build(root)
358
+ if args.out:
359
+ target = Path(args.out)
360
+ if not target.is_absolute():
361
+ target = root / target
362
+ write_text(target, text if text.endswith("\n") else text + "\n")
363
+ print(f"report written to {rel(target, root)}")
364
+ else:
365
+ print(text)
366
+ return code
367
+
368
+
369
+ def cmd_selftest(args: argparse.Namespace) -> int:
370
+ import subprocess
371
+ runner = PACK_ROOT / "tests" / "run_tests.py"
372
+ if not runner.exists():
373
+ print("ghagent: selftest needs the source tree: tests/run_tests.py is not part of the "
374
+ f"installed package, so this copy cannot be self-tested. Clone {CLONE_URL} and run "
375
+ "`python tests/run_tests.py` from the clone.", file=sys.stderr)
376
+ return EXIT_USAGE
377
+ proc = subprocess.run([sys.executable, str(runner), "--quiet"], capture_output=True, text=True,
378
+ encoding="utf-8", errors="replace")
379
+ sys.stdout.write(proc.stdout)
380
+ sys.stderr.write(proc.stderr)
381
+ if proc.returncode != 0:
382
+ print("ghagent: selftest FAILED — the installed copy does not behave as tested", file=sys.stderr)
383
+ else:
384
+ print(f"ghagent {PACK_VERSION} selftest: PASS")
385
+ return proc.returncode
386
+
387
+
388
+ def build_parser() -> argparse.ArgumentParser:
389
+ parser = argparse.ArgumentParser(prog="ghagent", description=f"{PACK_NAME} {PACK_VERSION} — repository and wiki steward")
390
+ parser.add_argument("--version", action="version", version=f"{PACK_NAME} {PACK_VERSION}")
391
+ subparsers = parser.add_subparsers(dest="command", required=True)
392
+
393
+ audit_parser = subparsers.add_parser("audit", help="repository hygiene, README and changelog contracts")
394
+ audit_parser.add_argument("path", nargs="?", default=".")
395
+ audit_parser.add_argument("--repo", default="", help="owner/name: audit the remote metadata instead")
396
+ audit_parser.add_argument("--http", action="store_true", help="also check that the homepage answers")
397
+ audit_parser.add_argument("--min-score", type=float, default=0.0)
398
+ _add_json(audit_parser)
399
+ audit_parser.set_defaults(func=cmd_audit)
400
+
401
+ doc_parser = subparsers.add_parser("doc", help="markdown lint, links, anchors, tables, privacy")
402
+ doc_parser.add_argument("paths", nargs="*")
403
+ doc_parser.add_argument("--root", default=".")
404
+ doc_parser.add_argument("--no-privacy", action="store_true")
405
+ doc_parser.add_argument("--no-toc", action="store_true")
406
+ doc_parser.add_argument("--include-wiki", action="store_true", help="also lint the wiki source directory")
407
+ _add_ignore(doc_parser)
408
+ _add_json(doc_parser)
409
+ doc_parser.set_defaults(func=cmd_doc)
410
+
411
+ privacy_parser = subparsers.add_parser("privacy", help="secrets, absolute paths, personal addresses")
412
+ privacy_parser.add_argument("--root", default=".")
413
+ _add_json(privacy_parser)
414
+ privacy_parser.set_defaults(func=cmd_privacy)
415
+
416
+ assets_parser = subparsers.add_parser("assets", help="banner and social preview: render, check, suggest")
417
+ assets_parser.add_argument("action", choices=["render", "check", "suggest"])
418
+ assets_parser.add_argument("--root", default=".")
419
+ assets_parser.add_argument("--title", default="")
420
+ assets_parser.add_argument("--tagline", default="")
421
+ assets_parser.add_argument("--command", default="")
422
+ assets_parser.add_argument("--chips", default="")
423
+ assets_parser.add_argument("--size", default="1280x640")
424
+ assets_parser.add_argument("--out-dir", default="assets")
425
+ assets_parser.add_argument("--write", action="store_true")
426
+ _add_json(assets_parser)
427
+ assets_parser.set_defaults(func=cmd_assets)
428
+
429
+ wiki_parser = subparsers.add_parser("wiki", help="build, verify and publish the GitHub wiki")
430
+ wiki_parser.add_argument("action", choices=["init", "build", "verify", "publish"])
431
+ wiki_parser.add_argument("--root", default=".")
432
+ wiki_parser.add_argument("--repo", default="")
433
+ wiki_parser.add_argument("--title", default="Project")
434
+ wiki_parser.add_argument("--wiki-dir", default="wiki")
435
+ wiki_parser.add_argument("--manifest", default="wiki/wiki.json")
436
+ wiki_parser.add_argument("--out", default="")
437
+ wiki_parser.add_argument("--push", action="store_true", help="actually push to the wiki remote")
438
+ wiki_parser.add_argument("--prune", action="store_true", help="delete remote pages absent from the build")
439
+ wiki_parser.add_argument("--force", action="store_true", help="publish even if verification failed")
440
+ wiki_parser.add_argument("--no-docs", action="store_true")
441
+ wiki_parser.add_argument("--message", default="")
442
+ wiki_parser.add_argument("--author-name", default="")
443
+ wiki_parser.add_argument("--author-email", default="")
444
+ wiki_parser.add_argument("--workdir", default="")
445
+ wiki_parser.add_argument("--remote-url", default="", help="override the wiki remote (testing and mirrors)")
446
+ _add_json(wiki_parser)
447
+ wiki_parser.set_defaults(func=cmd_wiki)
448
+
449
+ changelog_parser = subparsers.add_parser("changelog", help="check, release and generate entries")
450
+ changelog_parser.add_argument("action", choices=["check", "release", "suggest", "notes"])
451
+ changelog_parser.add_argument("version", nargs="?", default="")
452
+ changelog_parser.add_argument("--root", default=".")
453
+ changelog_parser.add_argument("--file", default="")
454
+ changelog_parser.add_argument("--repo", default="")
455
+ changelog_parser.add_argument("--date", default="")
456
+ changelog_parser.add_argument("--since", default="")
457
+ changelog_parser.add_argument("--include-noise", action="store_true")
458
+ changelog_parser.add_argument("--write", action="store_true")
459
+ _add_json(changelog_parser)
460
+ changelog_parser.set_defaults(func=cmd_changelog)
461
+
462
+ meta_parser = subparsers.add_parser("meta", help="description, topics and homepage")
463
+ meta_parser.add_argument("action", choices=["suggest", "check", "apply"])
464
+ meta_parser.add_argument("--root", default=".")
465
+ meta_parser.add_argument("--repo", default="")
466
+ meta_parser.add_argument("--description", default=None)
467
+ meta_parser.add_argument("--topics", default="")
468
+ meta_parser.add_argument("--homepage", default=None)
469
+ meta_parser.add_argument("--replace-topics", action="store_true")
470
+ meta_parser.add_argument("--write", action="store_true")
471
+ _add_json(meta_parser)
472
+ meta_parser.set_defaults(func=cmd_meta)
473
+
474
+ for name, handler, help_text in [
475
+ ("install", cmd_install, "copy the pack into an agent skill root"),
476
+ ("verify-install", cmd_verify_install, "compare an installed copy with its manifest"),
477
+ ("uninstall", cmd_uninstall, "remove an installed pack (needs its manifest)"),
478
+ ("snippet", cmd_snippet, "print the AGENTS.md block that points an agent at this pack"),
479
+ ]:
480
+ sub = subparsers.add_parser(name, help=help_text)
481
+ sub.add_argument("--profile", default="autoclaw", choices=["autoclaw", "harness", "saga"])
482
+ sub.add_argument("--target", default="")
483
+ sub.add_argument("--write", action="store_true")
484
+ _add_json(sub)
485
+ sub.set_defaults(func=handler)
486
+
487
+ report_parser = subparsers.add_parser("report", help="one markdown report from every check")
488
+ report_parser.add_argument("--root", default=".")
489
+ report_parser.add_argument("--out", default="")
490
+ report_parser.set_defaults(func=cmd_report)
491
+
492
+ selftest_parser = subparsers.add_parser("selftest", help="run the pack's own test suite")
493
+ selftest_parser.set_defaults(func=cmd_selftest)
494
+ return parser
495
+
496
+
497
+ def main(argv: list[str] | None = None) -> int:
498
+ force_utf8_stdio()
499
+ parser = build_parser()
500
+ args = parser.parse_args(argv)
501
+ try:
502
+ return args.func(args)
503
+ except KeyboardInterrupt:
504
+ return EXIT_USAGE
505
+ except Exception as exc: # noqa: BLE001 - a traceback is a finding, but exit 1 must still be honest
506
+ print(f"ghagent: internal error: {type(exc).__name__}: {exc}", file=sys.stderr)
507
+ return EXIT_FAIL
508
+
509
+
510
+ if __name__ == "__main__":
511
+ sys.exit(main())
ghalib/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """ghalib — the library behind the ghagent CLI (standard library only)."""
2
+
3
+ from .util import PACK_NAME, PACK_VERSION
4
+
5
+ __all__ = ["PACK_NAME", "PACK_VERSION"]