cli-tools-kit 0.6.4__tar.gz → 0.8.1__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.
Files changed (43) hide show
  1. {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/PKG-INFO +35 -1
  2. {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/README.md +34 -0
  3. {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit/__init__.py +1 -1
  4. {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit/advertise.py +5 -0
  5. cli_tools_kit-0.8.1/cli_tools_kit/autostart_gate.py +292 -0
  6. {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit/gui_installer.py +342 -16
  7. {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit/sources.py +398 -9
  8. {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit/tool_installer.py +5 -0
  9. {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit.egg-info/PKG-INFO +35 -1
  10. {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit.egg-info/SOURCES.txt +2 -0
  11. {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit.egg-info/entry_points.txt +1 -0
  12. {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/pyproject.toml +4 -1
  13. cli_tools_kit-0.8.1/tests/test_autostart_gate.py +236 -0
  14. {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/tests/test_sources.py +439 -0
  15. {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/LICENSE +0 -0
  16. {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit/__main__.py +0 -0
  17. {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit/cron_installer.py +0 -0
  18. {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit/host.py +0 -0
  19. {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit/identity.py +0 -0
  20. {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit/onboarding.py +0 -0
  21. {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit/skills.py +0 -0
  22. {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit/taxonomy/__init__.py +0 -0
  23. {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit/taxonomy/build.py +0 -0
  24. {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit/taxonomy/capability.py +0 -0
  25. {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit/taxonomy/cluster.py +0 -0
  26. {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit/taxonomy/corpus.py +0 -0
  27. {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit/taxonomy/embedder.py +0 -0
  28. {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit/taxonomy/groups.py +0 -0
  29. {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit/taxonomy/llm_groups.py +0 -0
  30. {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit/tui_installer.py +0 -0
  31. {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit.egg-info/dependency_links.txt +0 -0
  32. {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit.egg-info/requires.txt +0 -0
  33. {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit.egg-info/top_level.txt +0 -0
  34. {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/setup.cfg +0 -0
  35. {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/tests/test_capability_groups.py +0 -0
  36. {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/tests/test_cron_installer.py +0 -0
  37. {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/tests/test_host.py +0 -0
  38. {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/tests/test_identity.py +0 -0
  39. {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/tests/test_llm_groups.py +0 -0
  40. {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/tests/test_skills.py +0 -0
  41. {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/tests/test_taxonomy_cluster.py +0 -0
  42. {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/tests/test_tool_installer.py +0 -0
  43. {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/tests/test_tui_installer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cli-tools-kit
3
- Version: 0.6.4
3
+ Version: 0.8.1
4
4
  Summary: Installer protocol + helpers for self-installing Python CLI/GUI tools (desktop shortcuts, bash aliases, cron entries), plus reusable tkinter and curses installer screens
5
5
  Author: Steffen Probst
6
6
  License: MIT License
@@ -403,6 +403,37 @@ name = "manim-kit"
403
403
  url = "https://github.com/AutomatedAlchemy/manim-kit"
404
404
  ```
405
405
 
406
+ An entry may name a GitHub organisation instead of one repo. The installer lists
407
+ the org's repos, keeps the ones carrying a topic, and turns each into an ordinary
408
+ source, so a new tool in the org appears without anyone editing this file:
409
+
410
+ ```toml
411
+ [[source]]
412
+ org = "AutomatedAlchemy"
413
+ topic = "cli-tool-kit" # the default when omitted
414
+ exclude = ["alchemy-installer"] # repo names to skip
415
+ include = ["manim-kit"] # allowlist; wins over exclude
416
+ ```
417
+
418
+ The topic decides what is cloned, and the walker plus the `--advertise` probe
419
+ decide what is a tool: a repo that carries the topic but holds no tool clones,
420
+ advertises nothing and is dropped like any other directory. `org` cannot be
421
+ combined with `url` or `path`, and an explicit `[[source]]` with the same `name`
422
+ as a listed repo wins, so one tool can be pinned to a fork or a local checkout
423
+ while the rest of the org follows the listing. Archived repos are left out.
424
+ `include` is an allowlist and overrides `exclude`; the topic is required either
425
+ way. A `path` in `installer.local.toml` pins a listed repo by its name just as it
426
+ pins a tracked source, so a checkout already on the machine is used instead of
427
+ being cloned.
428
+
429
+ The listing is one `GET` to `api.github.com`, cached for a day under the
430
+ identity's cache directory, and `--refresh` fetches again. With the GitHub CLI
431
+ logged in, its token is used and the org's private repos are listed too; without
432
+ it the public listing is used and nothing is required. When the listing fails the
433
+ cached one is used however old it is, and with no cache at all the directories
434
+ already under the root are used — both say so in one line. `--check` never
435
+ fetches: it reads the cache, or the root.
436
+
406
437
  `installer.local.toml` next to it is optional and belongs to one machine, so keep
407
438
  it out of git. It sets the root and replaces a `path` for a source matched by
408
439
  `name`:
@@ -442,6 +473,9 @@ colleague without access to a private repo still gets everybody else's tools.
442
473
  given by `path` is never pulled. Cloning happens in the engine's `pre_discovery`
443
474
  hook, which `--check` skips, so the login check stays network-free.
444
475
 
476
+ An `org` entry is the only thing in this module that reaches anything but git,
477
+ it is opt-in per entry, and nothing is fetched when no such entry exists.
478
+
445
479
  A repo that is itself an installer tree can carry its own `installer.toml`. Its
446
480
  `[[source]]` entries are resolved too, one nested level deep and no further, with
447
481
  paths relative to that file and clones under the same root. A path that is
@@ -350,6 +350,37 @@ name = "manim-kit"
350
350
  url = "https://github.com/AutomatedAlchemy/manim-kit"
351
351
  ```
352
352
 
353
+ An entry may name a GitHub organisation instead of one repo. The installer lists
354
+ the org's repos, keeps the ones carrying a topic, and turns each into an ordinary
355
+ source, so a new tool in the org appears without anyone editing this file:
356
+
357
+ ```toml
358
+ [[source]]
359
+ org = "AutomatedAlchemy"
360
+ topic = "cli-tool-kit" # the default when omitted
361
+ exclude = ["alchemy-installer"] # repo names to skip
362
+ include = ["manim-kit"] # allowlist; wins over exclude
363
+ ```
364
+
365
+ The topic decides what is cloned, and the walker plus the `--advertise` probe
366
+ decide what is a tool: a repo that carries the topic but holds no tool clones,
367
+ advertises nothing and is dropped like any other directory. `org` cannot be
368
+ combined with `url` or `path`, and an explicit `[[source]]` with the same `name`
369
+ as a listed repo wins, so one tool can be pinned to a fork or a local checkout
370
+ while the rest of the org follows the listing. Archived repos are left out.
371
+ `include` is an allowlist and overrides `exclude`; the topic is required either
372
+ way. A `path` in `installer.local.toml` pins a listed repo by its name just as it
373
+ pins a tracked source, so a checkout already on the machine is used instead of
374
+ being cloned.
375
+
376
+ The listing is one `GET` to `api.github.com`, cached for a day under the
377
+ identity's cache directory, and `--refresh` fetches again. With the GitHub CLI
378
+ logged in, its token is used and the org's private repos are listed too; without
379
+ it the public listing is used and nothing is required. When the listing fails the
380
+ cached one is used however old it is, and with no cache at all the directories
381
+ already under the root are used — both say so in one line. `--check` never
382
+ fetches: it reads the cache, or the root.
383
+
353
384
  `installer.local.toml` next to it is optional and belongs to one machine, so keep
354
385
  it out of git. It sets the root and replaces a `path` for a source matched by
355
386
  `name`:
@@ -389,6 +420,9 @@ colleague without access to a private repo still gets everybody else's tools.
389
420
  given by `path` is never pulled. Cloning happens in the engine's `pre_discovery`
390
421
  hook, which `--check` skips, so the login check stays network-free.
391
422
 
423
+ An `org` entry is the only thing in this module that reaches anything but git,
424
+ it is opt-in per entry, and nothing is fetched when no such entry exists.
425
+
392
426
  A repo that is itself an installer tree can carry its own `installer.toml`. Its
393
427
  `[[source]]` entries are resolved too, one nested level deep and no further, with
394
428
  paths relative to that file and clones under the same root. A path that is
@@ -56,4 +56,4 @@ __all__ = [
56
56
  "read_installed_skill",
57
57
  ]
58
58
 
59
- __version__ = "0.6.1"
59
+ __version__ = "0.8.1"
@@ -72,6 +72,11 @@ def advertise(metadata: Union[ToolMetadata, List[ToolMetadata]]) -> NoReturn:
72
72
  value = getattr(m, key, None)
73
73
  if value:
74
74
  record[key] = value
75
+ # Conditions are a list, so they get the same emit-only-when-set
76
+ # treatment but keep their type.
77
+ conditions = getattr(m, "autostart_conditions", None)
78
+ if conditions:
79
+ record["autostart_conditions"] = list(conditions)
75
80
  out.append(record)
76
81
  print(json.dumps(out))
77
82
  sys.exit(0)
@@ -0,0 +1,292 @@
1
+ """Conditional autostart — run a tool at login only when a condition holds.
2
+
3
+ An autostart entry is all-or-nothing: the `.desktop` symlink in
4
+ `~/.config/autostart` either launches the tool at every login or never. Some
5
+ tools only make sense some of the time — a study launcher during study hours,
6
+ a voice assistant on the home network and nowhere else.
7
+
8
+ This module is the generic half of that. A tool declares WHICH conditions it
9
+ supports (`autostart_conditions` in its metadata); the user fills in the VALUES
10
+ through the installer's ⚙ dialog, which stores them per host in the installer's
11
+ own config directory. Nothing personal — no hours, no network names — belongs
12
+ in a tool's source.
13
+
14
+ The gate runs as a wrapper. When a tool has conditions configured, the
15
+ installer writes the autostart `.desktop` with an Exec line of
16
+
17
+ cli-tools-kit-autostart-gate --slug <installer> --tool <key> -- <real command>
18
+
19
+ The gate evaluates the stored conditions, execs the real command when they all
20
+ pass, and exits 0 silently when any fails. Exit 0 matters: a non-zero exit from
21
+ an XDG autostart entry makes systemd log a failed unit at every login.
22
+
23
+ Two conditions ship here:
24
+
25
+ `time_window`
26
+ Wall-clock window, `{"from": "06:00", "to": "12:00"}`. A window whose end
27
+ is not after its start wraps midnight, so `22:00`–`02:00` is four hours
28
+ around midnight rather than an empty set.
29
+
30
+ `network`
31
+ Wi-Fi SSID, `{"ssids": [...], "grace_seconds": 120}`. At login the wireless
32
+ link is usually still associating, so a bare check would lose the race and
33
+ the tool would never start. The gate polls until one of the named SSIDs
34
+ appears or the grace period runs out, and fires at most once per run.
35
+
36
+ Both are evaluated at launch only. Neither watches for later changes: leaving
37
+ the network does not stop a tool the gate already started.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import argparse
43
+ import json
44
+ import os
45
+ import shutil
46
+ import subprocess
47
+ import sys
48
+ import time
49
+ from datetime import datetime, time as dtime
50
+ from typing import Dict, List, Optional, Sequence
51
+
52
+ from .identity import InstallerIdentity, LEGACY_IDENTITY
53
+
54
+ # Conditions a tool may name in `autostart_conditions`. A tool advertising a
55
+ # word outside this set is ignored rather than rejected, so a tool written for
56
+ # a newer kit still installs against an older one.
57
+ KNOWN_CONDITIONS = ("time_window", "network")
58
+
59
+ # Poll interval while waiting for the wireless link to associate.
60
+ _NETWORK_POLL_SECONDS = 3.0
61
+
62
+ # Cap on `grace_seconds`, so a typo cannot leave a gate process resident for
63
+ # the rest of the session.
64
+ _MAX_GRACE_SECONDS = 900
65
+
66
+
67
+ def identity_for_slug(slug: str) -> InstallerIdentity:
68
+ """The identity a slug names.
69
+
70
+ A slug alone cannot reconstruct an identity that overrides its directories,
71
+ so the legacy one is matched by name: its config lives in
72
+ ``~/.config/tools-installer``, not in ``~/.config/probable.work``, and the
73
+ gate has to read the same file the installer wrote.
74
+ """
75
+ if slug == LEGACY_IDENTITY.slug:
76
+ return LEGACY_IDENTITY
77
+ return InstallerIdentity(slug=slug)
78
+
79
+
80
+ def config_path(slug: str) -> str:
81
+ """Where one installer's autostart conditions live.
82
+
83
+ Beside that installer's other config, keyed by slug so several installers
84
+ on one host keep separate files.
85
+ """
86
+ return os.path.join(identity_for_slug(slug).config_path, "autostart.json")
87
+
88
+
89
+ def load_conditions(slug: str) -> Dict[str, dict]:
90
+ """Read every tool's configured conditions for one installer.
91
+
92
+ Returns `{}` when the file is missing or unreadable — an absent config
93
+ means "no conditions", which gates open rather than shut.
94
+ """
95
+ try:
96
+ with open(config_path(slug), "r", encoding="utf-8") as fh:
97
+ data = json.load(fh)
98
+ except (OSError, ValueError):
99
+ return {}
100
+ return data if isinstance(data, dict) else {}
101
+
102
+
103
+ def load_tool_conditions(slug: str, tool_key: str) -> dict:
104
+ """The configured conditions for one tool, or `{}` if it has none."""
105
+ entry = load_conditions(slug).get(tool_key)
106
+ return entry if isinstance(entry, dict) else {}
107
+
108
+
109
+ def save_tool_conditions(slug: str, tool_key: str, conditions: Optional[dict]) -> None:
110
+ """Store (or, with *conditions* empty/None, drop) one tool's conditions."""
111
+ data = load_conditions(slug)
112
+ if conditions:
113
+ data[tool_key] = conditions
114
+ else:
115
+ data.pop(tool_key, None)
116
+
117
+ path = config_path(slug)
118
+ os.makedirs(os.path.dirname(path), exist_ok=True)
119
+ tmp = path + ".tmp"
120
+ with open(tmp, "w", encoding="utf-8") as fh:
121
+ json.dump(data, fh, indent=2, sort_keys=True)
122
+ fh.write("\n")
123
+ os.replace(tmp, path)
124
+
125
+
126
+ # ---------------------------------------------------------------- conditions
127
+
128
+ def _parse_hhmm(value: str) -> Optional[dtime]:
129
+ """Parse `"HH:MM"`. Returns None on anything unparseable."""
130
+ try:
131
+ hh, _, mm = str(value).partition(":")
132
+ return dtime(int(hh), int(mm))
133
+ except (TypeError, ValueError):
134
+ return None
135
+
136
+
137
+ def time_window_open(config: dict, now: Optional[datetime] = None) -> bool:
138
+ """True when *now* falls inside the configured window.
139
+
140
+ A window whose end is not after its start wraps midnight. An unparseable
141
+ or half-specified window is treated as no restriction.
142
+ """
143
+ start = _parse_hhmm(config.get("from", ""))
144
+ end = _parse_hhmm(config.get("to", ""))
145
+ if start is None or end is None:
146
+ return True
147
+
148
+ current = (now or datetime.now()).time()
149
+ if start < end:
150
+ return start <= current < end
151
+ if start == end:
152
+ # A zero-width window would never open; read it as "no restriction"
153
+ # rather than silently disabling the tool forever.
154
+ return True
155
+ # Wraps midnight: inside means after the start OR before the end.
156
+ return current >= start or current < end
157
+
158
+
159
+ def current_ssids() -> List[str]:
160
+ """SSIDs of the currently active Wi-Fi connections.
161
+
162
+ Uses NetworkManager, the only mechanism present on the KDE/GNOME hosts this
163
+ kit targets. Returns `[]` when nmcli is missing or reports nothing, which
164
+ is indistinguishable from "not on Wi-Fi" — deliberately, since both mean
165
+ the network condition cannot be satisfied.
166
+ """
167
+ try:
168
+ result = subprocess.run(
169
+ ["nmcli", "-t", "-f", "ACTIVE,SSID", "device", "wifi"],
170
+ capture_output=True, text=True, timeout=10,
171
+ )
172
+ except (OSError, subprocess.SubprocessError):
173
+ return []
174
+ if result.returncode != 0:
175
+ return []
176
+
177
+ found = []
178
+ for line in result.stdout.splitlines():
179
+ active, _, ssid = line.partition(":")
180
+ if active == "yes" and ssid:
181
+ found.append(ssid)
182
+ return found
183
+
184
+
185
+ def network_matches(config: dict, *, wait: bool = True) -> bool:
186
+ """True when one of the configured SSIDs is active.
187
+
188
+ With *wait* set, polls for up to `grace_seconds` so a login that outruns
189
+ the wireless association still sees the network. An empty SSID list is no
190
+ restriction.
191
+ """
192
+ wanted = [s for s in config.get("ssids", []) if s]
193
+ if not wanted:
194
+ return True
195
+
196
+ try:
197
+ grace = float(config.get("grace_seconds", 120))
198
+ except (TypeError, ValueError):
199
+ grace = 120.0
200
+ grace = max(0.0, min(grace, _MAX_GRACE_SECONDS)) if wait else 0.0
201
+
202
+ deadline = time.monotonic() + grace
203
+ while True:
204
+ if any(ssid in wanted for ssid in current_ssids()):
205
+ return True
206
+ if time.monotonic() >= deadline:
207
+ return False
208
+ time.sleep(min(_NETWORK_POLL_SECONDS, max(0.1, deadline - time.monotonic())))
209
+
210
+
211
+ def conditions_pass(conditions: dict, *, wait: bool = True) -> tuple[bool, str]:
212
+ """Evaluate every configured condition. Returns (passed, reason).
213
+
214
+ *reason* names the first condition that failed, for the log line; it is
215
+ empty when everything passed. An unknown condition name is skipped rather
216
+ than failed, so a config written by a newer kit does not shut the gate.
217
+ """
218
+ window = conditions.get("time_window")
219
+ if isinstance(window, dict) and not time_window_open(window):
220
+ return False, (
221
+ f"outside time window {window.get('from', '?')}–{window.get('to', '?')}"
222
+ )
223
+
224
+ network = conditions.get("network")
225
+ if isinstance(network, dict) and not network_matches(network, wait=wait):
226
+ wanted = ", ".join(network.get("ssids", [])) or "?"
227
+ return False, f"network not one of [{wanted}]"
228
+
229
+ return True, ""
230
+
231
+
232
+ # --------------------------------------------------------------------- entry
233
+
234
+ def build_exec_prefix(slug: str, tool_key: str) -> List[str]:
235
+ """The argv prefix that wraps a gated tool's own command.
236
+
237
+ Prefers the installed `cli-tools-kit-autostart-gate` console script, which
238
+ survives the interpreter that wrote the entry moving or being rebuilt. Only
239
+ when that is not on PATH does it pin the current interpreter, since a
240
+ `python -m` line is the one form guaranteed to resolve the package.
241
+ """
242
+ script = shutil.which("cli-tools-kit-autostart-gate")
243
+ head = [script] if script else [sys.executable, "-m", "cli_tools_kit.autostart_gate"]
244
+ return head + ["--slug", slug, "--tool", tool_key, "--"]
245
+
246
+
247
+ def main(argv: Optional[Sequence[str]] = None) -> int:
248
+ parser = argparse.ArgumentParser(
249
+ prog="cli-tools-kit-autostart-gate",
250
+ description="Run a command only when its configured autostart conditions hold.",
251
+ )
252
+ parser.add_argument("--slug", required=True,
253
+ help="Installer slug whose autostart.json holds the conditions")
254
+ parser.add_argument("--tool", required=True,
255
+ help="Tool key within that config")
256
+ parser.add_argument("--no-wait", action="store_true",
257
+ help="Skip the network grace period; evaluate once and exit")
258
+ parser.add_argument("--check", action="store_true",
259
+ help="Report whether the gate would open, run nothing")
260
+ parser.add_argument("command", nargs=argparse.REMAINDER,
261
+ help="-- followed by the command to run when the gate opens")
262
+ args = parser.parse_args(argv)
263
+
264
+ conditions = load_tool_conditions(args.slug, args.tool)
265
+ passed, reason = conditions_pass(conditions, wait=not args.no_wait)
266
+
267
+ if args.check:
268
+ print("open" if passed else f"closed: {reason}")
269
+ return 0 if passed else 1
270
+
271
+ if not passed:
272
+ # Exit 0 on a closed gate: a non-zero exit from an XDG autostart entry
273
+ # surfaces as a failed systemd unit at every login.
274
+ print(f"autostart-gate: {args.tool} not started ({reason})", file=sys.stderr)
275
+ return 0
276
+
277
+ command = list(args.command)
278
+ if command and command[0] == "--":
279
+ command = command[1:]
280
+ if not command:
281
+ print("autostart-gate: no command given", file=sys.stderr)
282
+ return 2
283
+
284
+ try:
285
+ os.execvp(command[0], command)
286
+ except OSError as exc:
287
+ print(f"autostart-gate: cannot run {command[0]}: {exc}", file=sys.stderr)
288
+ return 127
289
+
290
+
291
+ if __name__ == "__main__":
292
+ sys.exit(main())