aicp-cli 0.3.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.
aicp/cli.py ADDED
@@ -0,0 +1,595 @@
1
+ """Command-line entry point (``aicp = "aicp.cli:main"`` in pyproject.toml).
2
+
3
+ Port of ``aicp()`` and the argument parser at the foot of ``~/scripts/bin/aicp``.
4
+
5
+ This module is the only place the feature modules meet. Everything it needs
6
+ already exists and is tested — the fallback-chain runner, the ``.aicprc``
7
+ layer, the git guards, the secret scanner, the skills probe, the settings
8
+ menu — so the job here is wiring, in the one order that is load-bearing:
9
+
10
+ * **git first, an AI CLI only if git says there is work.** A clean, in-sync
11
+ repo answers both of aicp's questions from git alone and exits 0 with no AI
12
+ CLI installed at all; a blanket preflight at the top would turn that into a
13
+ failure. :func:`aicp.gitflow.precheck` owns that ordering (status, then the
14
+ CLI check, then the expensive tree scan) and is called instead of being
15
+ re-implemented here.
16
+ * **Every figure in the RESULT table is re-read from git**, never taken from a
17
+ CLI's own output — :func:`aicp.gitflow.result_summary` does the re-reading.
18
+ * **The notifier is injected, not imported by its consumer.** ``contracts.py``
19
+ makes this module the designated call site: :func:`aicp.notify.notify` is
20
+ passed into :func:`aicp.runner.run_step` as a parameter, which is what keeps
21
+ the runner testable with no network.
22
+
23
+ It is also the designated **config -> consumer bridge** — see
24
+ :func:`export_settings`.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import contextlib
31
+ import json
32
+ import os
33
+ import subprocess
34
+ import sys
35
+ from collections.abc import Sequence
36
+ from pathlib import Path
37
+ from typing import NoReturn
38
+
39
+ from . import __version__, config, gitflow, i18n, menu, notify, present, runner, skills
40
+ from ._utils import BLUE, BOLD, CYAN, DIM, GREEN, MAGENTA, RED, RESET, YELLOW, have
41
+ from .contracts import ROSTER
42
+ from .i18n import t
43
+
44
+ __all__ = ["build_parser", "export_settings", "main"]
45
+
46
+ _BY_NAME = {entry.name: entry for entry in ROSTER}
47
+
48
+ #: How many commits each side of the "not in sync" tail prints, on screen and
49
+ #: in the notification — the zsh original's `head -5`.
50
+ _TAIL = 5
51
+
52
+
53
+ # ── the config -> consumer bridge (defect D3) ────────────────────────────────
54
+
55
+
56
+ def export_settings(settings: config.Settings) -> None:
57
+ """Put the resolved ``AICP_*`` values into ``os.environ``.
58
+
59
+ ``config.resolve()`` validates knobs that **no consumer ever reads from
60
+ it**: :mod:`aicp.budget` and :mod:`aicp.gitflow` read ``os.environ``
61
+ directly, and correctly so — ``contracts.py`` forbids them importing the
62
+ config layer. Without this bridge an ``AICP_TZ`` or ``AICP_TIMEOUT_BASE``
63
+ written in ``.aicprc`` was parsed, validated, reported in ``Settings`` —
64
+ and then silently ignored by the only two modules that act on it.
65
+
66
+ ``setdefault`` is the whole precedence rule: a real environment variable is
67
+ already in ``os.environ`` and is therefore left exactly as it was, so
68
+ **environment still beats file**. Only a key the file alone supplied is
69
+ added.
70
+
71
+ ``lang`` is bridged separately because its consumer does not read the
72
+ environment per call: :data:`aicp.i18n.LANGUAGE` is resolved once at
73
+ import, which is strictly before this function can run, so exporting
74
+ ``AICP_LANG`` alone would leave every run English no matter what the
75
+ ``--config`` menu wrote. ``settings.lang`` is already validated against
76
+ the same rule :func:`aicp.i18n._resolve_language` applies, and this is its
77
+ only reader.
78
+
79
+ Safe by construction with respect to :data:`aicp.config.DENYLIST`:
80
+ ``settings.values`` is what :func:`aicp.config.load_config` accepted, and
81
+ that loader drops every denied key — ``AICP_TG_SEND`` (reaches ``bash
82
+ "$value"``), ``AICP_TIMING_LOG`` (path-mutating sinks) and ``AICP_CONFIG``
83
+ (would re-point the next ``persist_key`` write) — before they ever reach
84
+ here. So a file can never smuggle an exec-path or path-mutation knob into
85
+ the environment through this door. Whoever adds the next such knob adds it
86
+ to ``DENYLIST``, not to a filter here.
87
+
88
+ Two things this deliberately does NOT close, both stated rather than left
89
+ to be discovered:
90
+
91
+ * ``AICP_SKIP_SECRET_SCAN=1`` in a ``.aicprc`` really does switch that
92
+ run's secret scan off. That is parity with the zsh original (whose
93
+ loader ``typeset -g``s the same key) and is what ``.aicprc.example``
94
+ documents; it is accepted knowingly, not overlooked. Denying it would be
95
+ a behaviour change worth making on purpose, not as a side effect here.
96
+ * ``os.environ`` is inherited by children, where zsh's ``typeset -g`` was
97
+ a shell global that was not exported. Every ``AICP_*`` key a file
98
+ supplies is therefore visible to git and to the AI CLI subprocess. The
99
+ blast radius is bounded by the loader's ``AICP_*`` key allowlist, so no
100
+ unrelated variable can be set this way.
101
+ """
102
+ for key, value in settings.values.items():
103
+ os.environ.setdefault(key, value)
104
+ i18n.LANGUAGE = settings.lang
105
+
106
+
107
+ # ── small local helpers ──────────────────────────────────────────────────────
108
+
109
+
110
+ def _git_out(*args: str) -> str:
111
+ """``git <args>`` stdout, or "" if git fails — the cwd is the repo."""
112
+ try:
113
+ done = subprocess.run(["git", *args], capture_output=True, text=True, check=False)
114
+ except OSError: # git missing, or cwd gone
115
+ return ""
116
+ return done.stdout.strip() if done.returncode == 0 else ""
117
+
118
+
119
+ def _echo(lines: Sequence[str]) -> None:
120
+ for line in lines:
121
+ print(line)
122
+
123
+
124
+ def _dim(text: str) -> None:
125
+ print(f"{DIM}{text}{RESET}")
126
+
127
+
128
+ def _spin(label: str, verbose: bool):
129
+ """Animate *label* while an otherwise silent step runs.
130
+
131
+ ``--verbose`` streams instead, the same rule ``run_step`` follows; and
132
+ :class:`aicp.present.Spinner` disables itself off a TTY, so a non-TTY run
133
+ (this suite, CI, cron) prints exactly what it printed before.
134
+ """
135
+ return contextlib.nullcontext() if verbose else present.Spinner(label)
136
+
137
+
138
+ def _tilde(path: str) -> str:
139
+ home = os.path.expanduser("~")
140
+ return f"~{path[len(home):]}" if home and path.startswith(home) else path
141
+
142
+
143
+ def _log_lines(rev_range: str) -> list[str]:
144
+ return _git_out("log", "--oneline", rev_range).splitlines()[:_TAIL]
145
+
146
+
147
+ def _nudge(chain: Sequence[str]) -> None:
148
+ """One dim, non-blocking line when the CLI about to run has no skills.
149
+
150
+ Hot path: only the CLI that will actually be tried first is probed, and
151
+ :func:`aicp.skills.missing_skills` is existence-only (one ``stat`` per
152
+ target, no reads, no sidecar parsing).
153
+ """
154
+ for name in chain:
155
+ if not have(name):
156
+ continue
157
+ entry = _BY_NAME.get(name)
158
+ if entry is None:
159
+ return
160
+ missing = skills.missing_skills(entry)
161
+ if missing:
162
+ _dim(
163
+ t(
164
+ "skill_nudge",
165
+ "▸ %s is missing the %s skill — run aicp --config to install it",
166
+ name,
167
+ ", ".join(missing),
168
+ )
169
+ )
170
+ return
171
+
172
+
173
+ # ── argument parsing ─────────────────────────────────────────────────────────
174
+
175
+
176
+ class _Parser(argparse.ArgumentParser):
177
+ """argparse's own ``error`` exits 2 and prints its full usage; the zsh
178
+ original named the offending argument and exited 1. Keep that contract —
179
+ it is the one this tool's callers and tests already rely on."""
180
+
181
+ def error(self, message: str) -> NoReturn:
182
+ print(f"aicp: {message}", file=sys.stderr)
183
+ self.print_usage(sys.stderr)
184
+ raise SystemExit(1)
185
+
186
+
187
+ def build_parser() -> argparse.ArgumentParser:
188
+ parser = _Parser(
189
+ prog="aicp",
190
+ # argparse's own furniture ("usage:", "options:", -h's text) stays in
191
+ # English: it is argparse's, not aicp's, and gettext-patching a stdlib
192
+ # module to translate four words is not worth the surprise.
193
+ description=t("help_description", "AI commit + push, with a git-verified result summary."),
194
+ # No abbreviation matching: `--conf` must not silently become
195
+ # `--config` (which runs the settings menu instead of a commit).
196
+ allow_abbrev=False,
197
+ )
198
+ parser.add_argument("--version", action="version", version=f"aicp {__version__}")
199
+ parser.add_argument(
200
+ "-v", "--verbose", action="store_true",
201
+ help=t("help_verbose", "stream each CLI's output live instead of a spinner"),
202
+ )
203
+ parser.add_argument(
204
+ "--undo", action="store_true",
205
+ help=t(
206
+ "help_undo",
207
+ "undo the last commit (git reset --soft HEAD^); refuses once it is "
208
+ "on the remote. Runs no AI CLI",
209
+ ),
210
+ )
211
+ parser.add_argument(
212
+ "--swap-ai", action="store_true",
213
+ help=t("help_swap_ai", "move a CLI to #1 in the fallback chain and save it to config.json"),
214
+ )
215
+ parser.add_argument(
216
+ "--config", action="store_true",
217
+ help=t("help_config", "settings menu: the two steps, the language, the CLI order"),
218
+ )
219
+ parser.add_argument(
220
+ "--doctor", action="store_true",
221
+ help=t("help_doctor", "report where each vendored skill stands for each CLI"),
222
+ )
223
+ parser.add_argument("--json", action="store_true", help=t("help_json", "--doctor as JSON"))
224
+ parser.add_argument(
225
+ "--install-skills", action="store_true",
226
+ help=t("help_install_skills", "install the vendored skills into every configured CLI"),
227
+ )
228
+ parser.add_argument(
229
+ "--yes", action="store_true",
230
+ help=t("help_yes", "--install-skills: actually write (without it, only a preview)"),
231
+ )
232
+ parser.add_argument(
233
+ "--force", action="store_true",
234
+ help=t("help_force", "--install-skills: replace a foreign file, keeping a .bak"),
235
+ )
236
+ return parser
237
+
238
+
239
+ # ── the CI escape hatches ────────────────────────────────────────────────────
240
+
241
+
242
+ def _doctor(*, as_json: bool) -> int:
243
+ if as_json:
244
+ print(json.dumps(skills.status_json(), indent=2, ensure_ascii=False))
245
+ return 0
246
+ rows = [(f"{s.cli} · {s.skill}", s.version or s.state) for s in skills.full_status()]
247
+ _echo(present.render(rows, t("skills_title", "SKILLS")))
248
+ return 0
249
+
250
+
251
+ def _install_skills(*, yes: bool, force: bool) -> int:
252
+ if not yes:
253
+ rows = [(f"{s.cli} · {s.skill}", s.state) for s in skills.full_status()]
254
+ _echo(present.render(rows, t("skills_title", "SKILLS")))
255
+ _dim(t("install_needs_yes", " re-run with --install-skills --yes to write"))
256
+ return 0
257
+ for result in skills.install(force=force):
258
+ backup = f" (backup: {result.backup})" if result.backup else ""
259
+ print(f" {CYAN}{result.cli}{RESET} · {result.skill}: {result.action}{backup}")
260
+ return 0
261
+
262
+
263
+ # ── the main flow (port of aicp()) ───────────────────────────────────────────
264
+
265
+
266
+ def _run_panel(repo: str, top: str, branch: str, remote: str, chain: Sequence[str]) -> None:
267
+ _echo(
268
+ present.render(
269
+ [
270
+ (
271
+ t("panel_repo", "repo"),
272
+ f"{MAGENTA}{BOLD}{repo}{RESET} {DIM}{_tilde(top)}{RESET}",
273
+ ),
274
+ (
275
+ t("panel_branch", "branch"),
276
+ f"{CYAN}{branch}{RESET} {DIM}→{RESET} {CYAN}{remote}/{branch}{RESET}",
277
+ ),
278
+ ],
279
+ "aicp",
280
+ mode="panel",
281
+ accent=BLUE,
282
+ )
283
+ )
284
+ _dim(t("active_chain", "▸ chain: %s", " → ".join(chain)))
285
+ print()
286
+
287
+
288
+ def _commit_step(
289
+ settings: config.Settings, chain: Sequence[str], before: str, *, verbose: bool
290
+ ) -> runner.StepResult | None:
291
+ """Run /commit when needed and return its handler metadata."""
292
+ if not settings.do_commit:
293
+ _dim(
294
+ t(
295
+ "skip_commit_config",
296
+ "▸ /commit is switched off in config (AICP_DO_COMMIT=0) — skipping",
297
+ )
298
+ )
299
+ return None
300
+ # Read AFTER the config gate, so a run with /commit switched off pays for
301
+ # no status call at all.
302
+ if not _git_out("status", "--porcelain"):
303
+ _dim(t("skip_commit_clean", "▸ no uncommitted changes — skipping /commit"))
304
+ return None
305
+
306
+ _nudge(chain)
307
+ result = runner.run_step("/commit", chain, notify=notify.notify, verbose=verbose)
308
+ if result.rc != 0:
309
+ return result
310
+
311
+ # Shown here, attached to the step that produced it, rather than after the
312
+ # push: the panel answers "what did /commit write?", which is stale by the
313
+ # time the push summary arrives. The RESULT table still counts the whole
314
+ # run, so a commit created by /safe-git-push (a rebase) is not lost.
315
+ rows = gitflow.commit_rows(before, _git_out("rev-parse", "HEAD"))
316
+ if rows:
317
+ print()
318
+ _echo(
319
+ present.render(
320
+ [(t("commit_handler", "Commit handler"), result.winner or "—"), *rows],
321
+ f'{t("commits_panel_title", "NEW COMMITS")} · {gitflow.tz_label()}',
322
+ mode="panel",
323
+ accent=GREEN,
324
+ zebra=True,
325
+ )
326
+ )
327
+ return result
328
+
329
+
330
+ def _push_step(
331
+ settings: config.Settings,
332
+ chain: Sequence[str],
333
+ remote: str,
334
+ branch: str,
335
+ *,
336
+ verbose: bool,
337
+ ) -> int | runner.StepResult | None:
338
+ """Run /safe-git-push when needed; ``int`` means preflight failed."""
339
+ if not settings.do_push:
340
+ _dim(
341
+ t(
342
+ "skip_push_config",
343
+ "▸ /safe-git-push is switched off in config (AICP_DO_PUSH=0) — skipping",
344
+ )
345
+ )
346
+ return None
347
+
348
+ # Ask git before asking an AI: only a divergence is worth a CLI call.
349
+ # Behind counts too — that is the rebase/pull case /safe-git-push exists
350
+ # for — so the step is skipped only when both sides are already identical.
351
+ # An unresolvable remote ref is never read as "in sync": it runs the step.
352
+ with _spin(t("spin_fetch", "git fetch %s/%s…", remote, branch), verbose):
353
+ work_to_do = gitflow.diverged(remote, branch)
354
+
355
+ if not work_to_do:
356
+ _dim(
357
+ t(
358
+ "skip_push_synced",
359
+ "▸ %s/%s already in sync — skipping /safe-git-push",
360
+ remote,
361
+ branch,
362
+ )
363
+ )
364
+ return None
365
+
366
+ ok, lines = gitflow.preflight(chain)
367
+ if not ok:
368
+ _echo(lines)
369
+ return 1
370
+ _nudge(chain)
371
+ return runner.run_step("/safe-git-push", chain, notify=notify.notify, verbose=verbose)
372
+
373
+
374
+ def _report(
375
+ summary: gitflow.ResultSummary,
376
+ repo: str,
377
+ remote: str,
378
+ branch: str,
379
+ *,
380
+ commit_handler: str | None,
381
+ push_handler: str | None,
382
+ ) -> int:
383
+ """Print the git-verified RESULT table and decide the exit code."""
384
+ if summary.fetch_note:
385
+ print(summary.fetch_note)
386
+ _echo(
387
+ present.render(
388
+ [
389
+ *summary.rows(),
390
+ (t("commit_handler", "Commit handler"), commit_handler or "—"),
391
+ (t("push_handler", "Push handler"), push_handler or "—"),
392
+ ],
393
+ t("result_title", "RESULT"),
394
+ )
395
+ )
396
+
397
+ if summary.in_sync:
398
+ return 0
399
+
400
+ if not summary.verifiable:
401
+ print()
402
+ print(
403
+ f"{RED}"
404
+ + t(
405
+ "cannot_verify",
406
+ "cannot verify the push: %s/%s does not resolve.",
407
+ remote,
408
+ branch,
409
+ )
410
+ + RESET
411
+ )
412
+ _dim(
413
+ t(
414
+ "cannot_verify_note",
415
+ " the remote may be unreachable, or the branch was never pushed.",
416
+ )
417
+ )
418
+ notify.notify(
419
+ t(
420
+ "tg_unverifiable",
421
+ "⚠️ aicp: cannot verify the push of %s on %s —— %s/%s does not resolve "
422
+ "(remote unreachable, or the branch was never pushed). %s new commit(s).",
423
+ branch,
424
+ repo,
425
+ remote,
426
+ branch,
427
+ summary.created,
428
+ )
429
+ )
430
+ return 1
431
+
432
+ local_title = t("local_not_pushed", "local not pushed:")
433
+ remote_title = t("remote_not_merged", "remote not merged:")
434
+ local = _log_lines(f"{remote}/{branch}..HEAD")
435
+ incoming = _log_lines(f"HEAD..{remote}/{branch}")
436
+
437
+ print()
438
+ for title, entries in ((local_title, local), (remote_title, incoming)):
439
+ print(f"{YELLOW}{title}{RESET}")
440
+ for line in entries:
441
+ print(f" {DIM}{line}{RESET}")
442
+
443
+ notify.notify(
444
+ t(
445
+ "tg_not_synced",
446
+ "⚠️ aicp: %s on %s is not in sync with %s/%s",
447
+ branch,
448
+ repo,
449
+ remote,
450
+ branch,
451
+ )
452
+ + "\n\n"
453
+ + f'| {t("tg_tbl_item", "Item")} | {t("tg_tbl_value", "Value")} |\n'
454
+ + "| --- | --- |\n"
455
+ # Same three labels as the RESULT table, so the same msgids: the
456
+ # Telegram row and the terminal row must never read differently.
457
+ + f'| {t("result_new_commits", "New commits")} | {summary.created} |\n'
458
+ + f'| {t("result_ahead", "Ahead")} | {summary.ahead} |\n'
459
+ + f'| {t("result_behind", "Behind")} | {summary.behind} |\n\n'
460
+ + local_title
461
+ + "\n"
462
+ + "\n".join(local)
463
+ + "\n"
464
+ + remote_title
465
+ + "\n"
466
+ + "\n".join(incoming)
467
+ )
468
+ return 1
469
+
470
+
471
+ def _flow(settings: config.Settings, *, verbose: bool) -> int:
472
+ chain = settings.cli_chain
473
+ excluded: set[str] = set()
474
+
475
+ def active_chain() -> tuple[str, ...]:
476
+ return tuple(cli for cli in chain if cli not in excluded)
477
+
478
+ branch = gitflow.current_branch()
479
+ if branch is None:
480
+ _echo(gitflow.not_on_branch_lines())
481
+ return 1
482
+ remote = gitflow.remote_for(branch)
483
+ top = _git_out("rev-parse", "--show-toplevel")
484
+ repo = Path(top).name if top else ""
485
+ before = _git_out("rev-parse", "HEAD")
486
+
487
+ _run_panel(repo, top, branch, remote, chain)
488
+
489
+ # precheck owns the order that matters (git status, then "is any CLI
490
+ # installed", then the expensive secret scan) and refuses the run outright
491
+ # on a secret hit — no AI CLI is invoked on that path.
492
+ # ponytail: _commit_step re-reads `git status --porcelain` to decide
493
+ # "dirty" — one extra, already-warm call rather than widening precheck's
494
+ # frozen return shape. Revisit only if a repo is big enough to notice.
495
+ with _spin(t("spin_precheck", "checking the working tree…"), verbose):
496
+ proceed, lines = gitflow.precheck(chain, do_commit=settings.do_commit)
497
+ _echo(lines)
498
+ if not proceed:
499
+ return 1
500
+
501
+ commit_result = _commit_step(settings, active_chain(), before, verbose=verbose)
502
+ if commit_result is not None:
503
+ excluded.update(commit_result.quota_clis)
504
+ if commit_result.rc != 0:
505
+ return commit_result.rc
506
+ print()
507
+
508
+ push_result = _push_step(settings, active_chain(), remote, branch, verbose=verbose)
509
+ if isinstance(push_result, int):
510
+ return push_result
511
+ if push_result is not None:
512
+ excluded.update(push_result.quota_clis)
513
+ if push_result.rc == runner.ABORT_RC:
514
+ return push_result.rc
515
+ print()
516
+
517
+ # Trust git, not the CLI: read the real state back before reporting.
518
+ after = _git_out("rev-parse", "HEAD")
519
+ with _spin(t("spin_fetch", "git fetch %s/%s…", remote, branch), verbose):
520
+ summary = gitflow.result_summary(before, after, remote, branch)
521
+ return _report(
522
+ summary,
523
+ repo,
524
+ remote,
525
+ branch,
526
+ commit_handler=commit_result.winner if commit_result is not None else None,
527
+ push_handler=push_result.winner if isinstance(push_result, runner.StepResult) else None,
528
+ )
529
+
530
+
531
+ # ── entry point ──────────────────────────────────────────────────────────────
532
+
533
+
534
+ #: Sub-flag -> the flag it qualifies. A sub-flag on its own is refused rather
535
+ #: than ignored: `aicp --json` silently running a full commit and push, because
536
+ #: the user meant `--doctor --json`, is the one misread worth three lines.
537
+ _SUB_FLAGS = {"json": "doctor", "yes": "install_skills", "force": "install_skills"}
538
+
539
+
540
+ def _reject_orphan_sub_flags(parser: argparse.ArgumentParser, args: argparse.Namespace) -> None:
541
+ for sub, parent in _SUB_FLAGS.items():
542
+ if getattr(args, sub) and not getattr(args, parent):
543
+ parser.error(
544
+ f"--{sub} only applies to --{parent.replace('_', '-')}"
545
+ )
546
+
547
+
548
+ def _dispatch(args: argparse.Namespace) -> int:
549
+ settings = config.resolve()
550
+ export_settings(settings)
551
+
552
+ if args.doctor:
553
+ return _doctor(as_json=args.json)
554
+ if args.install_skills:
555
+ return _install_skills(yes=args.yes, force=args.force)
556
+ if args.config:
557
+ return menu.config_menu() or 0
558
+ if args.swap_ai:
559
+ return menu.swap_ai() or 0
560
+ if args.undo:
561
+ ok, lines = gitflow.undo()
562
+ _echo(lines)
563
+ return 0 if ok else 1
564
+
565
+ # First run: there is nothing to configure from, so ask once rather than
566
+ # committing with defaults the user never saw. Non-interactively the menu
567
+ # reads EOF as "quit", so CI is never blocked by this.
568
+ if not settings.path.exists():
569
+ menu.config_menu()
570
+ settings = config.resolve()
571
+ export_settings(settings)
572
+
573
+ return _flow(settings, verbose=args.verbose)
574
+
575
+
576
+ def main(argv: list[str] | None = None) -> int:
577
+ parser = build_parser()
578
+ try:
579
+ args = parser.parse_args(argv)
580
+ _reject_orphan_sub_flags(parser, args)
581
+ except SystemExit as exc: # --help/--version (0), or a bad flag (1)
582
+ return int(exc.code or 0)
583
+
584
+ try:
585
+ return _dispatch(args)
586
+ except KeyboardInterrupt:
587
+ print(
588
+ f"{YELLOW}⚠{RESET} "
589
+ + t("interrupted", "interrupted (Ctrl+C) — aborting, no further steps run")
590
+ )
591
+ return runner.ABORT_RC
592
+
593
+
594
+ if __name__ == "__main__":
595
+ raise SystemExit(main())