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.
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/PKG-INFO +35 -1
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/README.md +34 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit/__init__.py +1 -1
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit/advertise.py +5 -0
- cli_tools_kit-0.8.1/cli_tools_kit/autostart_gate.py +292 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit/gui_installer.py +342 -16
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit/sources.py +398 -9
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit/tool_installer.py +5 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit.egg-info/PKG-INFO +35 -1
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit.egg-info/SOURCES.txt +2 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit.egg-info/entry_points.txt +1 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/pyproject.toml +4 -1
- cli_tools_kit-0.8.1/tests/test_autostart_gate.py +236 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/tests/test_sources.py +439 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/LICENSE +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit/__main__.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit/cron_installer.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit/host.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit/identity.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit/onboarding.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit/skills.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit/taxonomy/__init__.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit/taxonomy/build.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit/taxonomy/capability.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit/taxonomy/cluster.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit/taxonomy/corpus.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit/taxonomy/embedder.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit/taxonomy/groups.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit/taxonomy/llm_groups.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit/tui_installer.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit.egg-info/dependency_links.txt +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit.egg-info/requires.txt +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/cli_tools_kit.egg-info/top_level.txt +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/setup.cfg +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/tests/test_capability_groups.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/tests/test_cron_installer.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/tests/test_host.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/tests/test_identity.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/tests/test_llm_groups.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/tests/test_skills.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/tests/test_taxonomy_cluster.py +0 -0
- {cli_tools_kit-0.6.4 → cli_tools_kit-0.8.1}/tests/test_tool_installer.py +0 -0
- {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.
|
|
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
|
|
@@ -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())
|