cli-tools-kit 0.7.1__tar.gz → 0.8.2__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.7.1 → cli_tools_kit-0.8.2}/PKG-INFO +1 -1
  2. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.2}/cli_tools_kit/__init__.py +1 -1
  3. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.2}/cli_tools_kit/advertise.py +5 -0
  4. cli_tools_kit-0.8.2/cli_tools_kit/autostart_gate.py +292 -0
  5. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.2}/cli_tools_kit/gui_installer.py +394 -30
  6. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.2}/cli_tools_kit/tool_installer.py +5 -0
  7. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.2}/cli_tools_kit.egg-info/PKG-INFO +1 -1
  8. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.2}/cli_tools_kit.egg-info/SOURCES.txt +2 -0
  9. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.2}/cli_tools_kit.egg-info/entry_points.txt +1 -0
  10. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.2}/pyproject.toml +4 -1
  11. cli_tools_kit-0.8.2/tests/test_autostart_gate.py +236 -0
  12. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.2}/LICENSE +0 -0
  13. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.2}/README.md +0 -0
  14. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.2}/cli_tools_kit/__main__.py +0 -0
  15. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.2}/cli_tools_kit/cron_installer.py +0 -0
  16. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.2}/cli_tools_kit/host.py +0 -0
  17. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.2}/cli_tools_kit/identity.py +0 -0
  18. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.2}/cli_tools_kit/onboarding.py +0 -0
  19. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.2}/cli_tools_kit/skills.py +0 -0
  20. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.2}/cli_tools_kit/sources.py +0 -0
  21. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.2}/cli_tools_kit/taxonomy/__init__.py +0 -0
  22. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.2}/cli_tools_kit/taxonomy/build.py +0 -0
  23. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.2}/cli_tools_kit/taxonomy/capability.py +0 -0
  24. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.2}/cli_tools_kit/taxonomy/cluster.py +0 -0
  25. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.2}/cli_tools_kit/taxonomy/corpus.py +0 -0
  26. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.2}/cli_tools_kit/taxonomy/embedder.py +0 -0
  27. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.2}/cli_tools_kit/taxonomy/groups.py +0 -0
  28. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.2}/cli_tools_kit/taxonomy/llm_groups.py +0 -0
  29. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.2}/cli_tools_kit/tui_installer.py +0 -0
  30. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.2}/cli_tools_kit.egg-info/dependency_links.txt +0 -0
  31. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.2}/cli_tools_kit.egg-info/requires.txt +0 -0
  32. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.2}/cli_tools_kit.egg-info/top_level.txt +0 -0
  33. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.2}/setup.cfg +0 -0
  34. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.2}/tests/test_capability_groups.py +0 -0
  35. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.2}/tests/test_cron_installer.py +0 -0
  36. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.2}/tests/test_host.py +0 -0
  37. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.2}/tests/test_identity.py +0 -0
  38. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.2}/tests/test_llm_groups.py +0 -0
  39. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.2}/tests/test_skills.py +0 -0
  40. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.2}/tests/test_sources.py +0 -0
  41. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.2}/tests/test_taxonomy_cluster.py +0 -0
  42. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.2}/tests/test_tool_installer.py +0 -0
  43. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.2}/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.7.1
3
+ Version: 0.8.2
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
@@ -56,4 +56,4 @@ __all__ = [
56
56
  "read_installed_skill",
57
57
  ]
58
58
 
59
- __version__ = "0.7.1"
59
+ __version__ = "0.8.2"
@@ -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())