cli-tools-kit 0.7.1__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.7.1 → cli_tools_kit-0.8.1}/PKG-INFO +1 -1
  2. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit/__init__.py +1 -1
  3. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit/advertise.py +5 -0
  4. cli_tools_kit-0.8.1/cli_tools_kit/autostart_gate.py +292 -0
  5. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit/gui_installer.py +342 -16
  6. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit/tool_installer.py +5 -0
  7. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit.egg-info/PKG-INFO +1 -1
  8. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit.egg-info/SOURCES.txt +2 -0
  9. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit.egg-info/entry_points.txt +1 -0
  10. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/pyproject.toml +4 -1
  11. cli_tools_kit-0.8.1/tests/test_autostart_gate.py +236 -0
  12. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/LICENSE +0 -0
  13. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/README.md +0 -0
  14. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit/__main__.py +0 -0
  15. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit/cron_installer.py +0 -0
  16. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit/host.py +0 -0
  17. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit/identity.py +0 -0
  18. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit/onboarding.py +0 -0
  19. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit/skills.py +0 -0
  20. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit/sources.py +0 -0
  21. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit/taxonomy/__init__.py +0 -0
  22. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit/taxonomy/build.py +0 -0
  23. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit/taxonomy/capability.py +0 -0
  24. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit/taxonomy/cluster.py +0 -0
  25. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit/taxonomy/corpus.py +0 -0
  26. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit/taxonomy/embedder.py +0 -0
  27. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit/taxonomy/groups.py +0 -0
  28. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit/taxonomy/llm_groups.py +0 -0
  29. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit/tui_installer.py +0 -0
  30. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit.egg-info/dependency_links.txt +0 -0
  31. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit.egg-info/requires.txt +0 -0
  32. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit.egg-info/top_level.txt +0 -0
  33. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/setup.cfg +0 -0
  34. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/tests/test_capability_groups.py +0 -0
  35. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/tests/test_cron_installer.py +0 -0
  36. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/tests/test_host.py +0 -0
  37. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/tests/test_identity.py +0 -0
  38. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/tests/test_llm_groups.py +0 -0
  39. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/tests/test_skills.py +0 -0
  40. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/tests/test_sources.py +0 -0
  41. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/tests/test_taxonomy_cluster.py +0 -0
  42. {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/tests/test_tool_installer.py +0 -0
  43. {cli_tools_kit-0.7.1 → 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.7.1
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
@@ -56,4 +56,4 @@ __all__ = [
56
56
  "read_installed_skill",
57
57
  ]
58
58
 
59
- __version__ = "0.7.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())
@@ -61,6 +61,14 @@ from typing import Callable, Dict, List, NamedTuple, Optional, Sequence
61
61
 
62
62
  from . import host
63
63
  from .identity import InstallerIdentity, LEGACY_IDENTITY
64
+ from .autostart_gate import (
65
+ KNOWN_CONDITIONS,
66
+ _parse_hhmm,
67
+ build_exec_prefix,
68
+ current_ssids,
69
+ load_tool_conditions,
70
+ save_tool_conditions,
71
+ )
64
72
 
65
73
  # Pillow renders tool icons. It is an optional extra (``cli-tools-kit[gui]``);
66
74
  # without it the GUI still runs, just without per-tool icon thumbnails, and the
@@ -255,6 +263,7 @@ class ToolEntry(NamedTuple):
255
263
  cron_args: List[str] = [] # Args to pass when running as cron job
256
264
  skill_name: str = "" # If non-empty, tool can install a Claude Code skill via --install-skill / --uninstall-skill
257
265
  skill_status: str = "" # Advertised skill freshness: "absent"|"current"|"stale" ("" = tool didn't report it)
266
+ autostart_conditions: List[str] = [] # Conditions this tool's autostart supports ("time_window", "network"); the values live in the installer's autostart.json, never in the tool. See cli_tools_kit.autostart_gate.
258
267
 
259
268
 
260
269
  def _group_label(entry: "ToolEntry") -> str:
@@ -476,6 +485,14 @@ def _find_emoji_font():
476
485
  "/usr/share/fonts/google-noto-color-emoji/NotoColorEmoji.ttf",
477
486
  "/usr/share/fonts/truetype/unifont/unifont.ttf",
478
487
  ]
488
+ # Windows ships Segoe UI Emoji; macOS ships Apple Color Emoji. Neither has
489
+ # fc-match, so without these the lookup below finds nothing and every emoji
490
+ # falls back to the monochrome glyph.
491
+ windir = os.environ.get("WINDIR", r"C:\Windows")
492
+ font_paths += [
493
+ os.path.join(windir, "Fonts", "seguiemj.ttf"),
494
+ "/System/Library/Fonts/Apple Color Emoji.ttc",
495
+ ]
479
496
  for path in font_paths:
480
497
  if os.path.exists(path):
481
498
  return path
@@ -517,17 +534,18 @@ def render_emoji_icon(emoji_char: str, output_path: str, size: int = 128) -> boo
517
534
 
518
535
  if font is not None:
519
536
  try:
520
- render_size = native_size + 60 # Extra room for centering
537
+ # Draw at a fixed offset on a canvas with room on every side,
538
+ # then let the crop below find the glyph. Centering by textbbox
539
+ # first would cut emoji off: for a character with a variation
540
+ # selector (U+2600 U+FE0F) Segoe UI Emoji reports a box half
541
+ # again as wide as what it actually paints, and the resulting
542
+ # offset pushes the right-hand side off the canvas.
543
+ render_size = native_size * 3
521
544
  img = Image.new("RGBA", (render_size, render_size), (0, 0, 0, 0))
522
545
  draw = ImageDraw.Draw(img)
523
546
 
524
- bbox = draw.textbbox((0, 0), emoji_char, font=font)
525
- w = bbox[2] - bbox[0]
526
- h = bbox[3] - bbox[1]
527
- x = (render_size - w) // 2 - bbox[0]
528
- y = (render_size - h) // 2 - bbox[1]
529
-
530
- draw.text((x, y), emoji_char, font=font, embedded_color=True)
547
+ origin = native_size // 2
548
+ draw.text((origin, origin), emoji_char, font=font, embedded_color=True)
531
549
 
532
550
  if img.getbbox():
533
551
  # Crop to content, resize to target with padding
@@ -652,6 +670,10 @@ def get_metadata_native(file_path: str, category: str) -> List[ToolEntry]:
652
670
  cron_args=item.get("cron_args", []),
653
671
  skill_name=item.get("skill_name", ""),
654
672
  skill_status=item.get("skill_status", ""),
673
+ autostart_conditions=[
674
+ c for c in item.get("autostart_conditions", []) or []
675
+ if c in KNOWN_CONDITIONS
676
+ ],
655
677
  ))
656
678
  except (subprocess.TimeoutExpired, json.JSONDecodeError, Exception):
657
679
  # If a tool fails to advertise, it is ignored.
@@ -1382,6 +1404,92 @@ def is_autostart_enabled(tool: ToolEntry) -> bool:
1382
1404
  return False
1383
1405
 
1384
1406
 
1407
+ def autostart_tool_key(tool: ToolEntry) -> str:
1408
+ """The key a tool's conditions are stored under.
1409
+
1410
+ The .desktop stem: stable across renames of the display name, and already
1411
+ unique per installed shortcut.
1412
+ """
1413
+ return os.path.splitext(tool.desktop_file)[0]
1414
+
1415
+
1416
+ def get_autostart_conditions(tool: ToolEntry) -> dict:
1417
+ """The conditions currently configured for *tool* on this host."""
1418
+ return load_tool_conditions(IDENTITY.slug, autostart_tool_key(tool))
1419
+
1420
+
1421
+ def set_autostart_conditions(tool: ToolEntry, conditions: Optional[dict]) -> None:
1422
+ """Store *tool*'s conditions, then rewrite its entry if autostart is on.
1423
+
1424
+ The Exec line differs between a gated and an ungated entry, so a change
1425
+ here only takes effect once the entry is rewritten.
1426
+ """
1427
+ save_tool_conditions(IDENTITY.slug, autostart_tool_key(tool), conditions)
1428
+ if is_autostart_enabled(tool):
1429
+ enable_autostart(tool)
1430
+
1431
+
1432
+ def _read_desktop_exec(desktop_path: str) -> str:
1433
+ """The Exec= line of an installed .desktop, or "" if it has none."""
1434
+ try:
1435
+ with open(desktop_path, "r", encoding="utf-8") as fh:
1436
+ for line in fh:
1437
+ if line.startswith("Exec="):
1438
+ return line[len("Exec="):].strip()
1439
+ except OSError:
1440
+ pass
1441
+ return ""
1442
+
1443
+
1444
+ def _write_gated_autostart(tool: ToolEntry, desktop_path: str,
1445
+ autostart_path: str, conditions: dict) -> tuple[bool, str]:
1446
+ """Write an autostart .desktop whose Exec runs *tool* through the gate.
1447
+
1448
+ A real file rather than the usual symlink: the app entry in the menu must
1449
+ keep launching the tool unconditionally, so only this copy carries the
1450
+ gate. Everything else is inherited from the installed entry.
1451
+ """
1452
+ exec_line = _read_desktop_exec(desktop_path)
1453
+ if not exec_line:
1454
+ return False, f"No Exec line in {desktop_path}"
1455
+
1456
+ prefix = " ".join(shlex.quote(p) for p in
1457
+ build_exec_prefix(IDENTITY.slug, autostart_tool_key(tool)))
1458
+ gated_exec = f"{prefix} {exec_line}"
1459
+
1460
+ try:
1461
+ with open(desktop_path, "r", encoding="utf-8") as fh:
1462
+ lines = fh.read().splitlines()
1463
+ except OSError as e:
1464
+ return False, f"Failed to read {desktop_path}: {e}"
1465
+
1466
+ out = []
1467
+ for line in lines:
1468
+ if line.startswith("Exec="):
1469
+ out.append(f"Exec={gated_exec}")
1470
+ elif line.startswith("X-CliToolsKit-Gated="):
1471
+ continue
1472
+ else:
1473
+ out.append(line)
1474
+ # Marks the entry as ours and generated, so it is obvious in a diff why
1475
+ # this one is a file where every other autostart entry is a symlink.
1476
+ out.append("X-CliToolsKit-Gated=true")
1477
+
1478
+ try:
1479
+ if os.path.exists(autostart_path) or os.path.islink(autostart_path):
1480
+ os.remove(autostart_path)
1481
+ with open(autostart_path, "w", encoding="utf-8") as fh:
1482
+ fh.write("\n".join(out) + "\n")
1483
+ # Deliberately not executable: systemd-xdg-autostart-generator warns
1484
+ # on every login about an executable entry in ~/.config/autostart.
1485
+ exec_bits = stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
1486
+ os.chmod(autostart_path, os.stat(autostart_path).st_mode & ~exec_bits)
1487
+ except OSError as e:
1488
+ return False, f"Failed to write {autostart_path}: {e}"
1489
+
1490
+ return True, f"Autostart enabled (conditional): {tool.name}"
1491
+
1492
+
1385
1493
  def enable_autostart(tool: ToolEntry) -> tuple[bool, str]:
1386
1494
  """Enable autostart for a tool.
1387
1495
 
@@ -1411,6 +1519,12 @@ def enable_autostart(tool: ToolEntry) -> tuple[bool, str]:
1411
1519
  os.makedirs(AUTOSTART_DIR, exist_ok=True)
1412
1520
  autostart_path = get_autostart_path(tool)
1413
1521
 
1522
+ # A tool with conditions configured gets a gated copy instead of the
1523
+ # plain symlink, so the menu entry stays unconditional.
1524
+ conditions = get_autostart_conditions(tool)
1525
+ if conditions:
1526
+ return _write_gated_autostart(tool, desktop_path, autostart_path, conditions)
1527
+
1414
1528
  if os.path.exists(autostart_path) or os.path.islink(autostart_path):
1415
1529
  os.remove(autostart_path)
1416
1530
 
@@ -1800,6 +1914,7 @@ class InstallerApp:
1800
1914
 
1801
1915
  self.check_vars: Dict[str, tk.BooleanVar] = {}
1802
1916
  self.autostart_vars: Dict[str, tk.BooleanVar] = {} # Autostart checkboxes
1917
+ self.autostart_gears: Dict[str, ttk.Label] = {} # ⚙ beside the checkbox, for tools with conditions
1803
1918
  self.skill_vars: Dict[str, tk.BooleanVar] = {} # Per-row "Skill" checkbox (only for tools with skill_name)
1804
1919
  self.status_labels: Dict[str, ttk.Label] = {}
1805
1920
  self.icon_labels: Dict[str, ttk.Label] = {} # For displaying tool icons
@@ -2175,6 +2290,9 @@ class InstallerApp:
2175
2290
  self.style.configure("CardToolName.TLabel", font=("", 10, "bold"),
2176
2291
  background=t["panel"], foreground=t["fg"])
2177
2292
  self.style.configure("CardMuted.TLabel", foreground=t["muted"], background=t["panel"])
2293
+ # The ⚙ on an autostart row: accent once conditions are configured, so
2294
+ # a glance at the column says which tools are gated.
2295
+ self.style.configure("CardAccent.TLabel", foreground=t["accent"], background=t["panel"])
2178
2296
  self.style.configure("Card.TCheckbutton", background=t["panel"], foreground=t["fg"])
2179
2297
  self.style.map("Card.TCheckbutton", background=[("active", t["panel"])])
2180
2298
  # Hover-emphasis card styles — a row lifts onto a slightly accent-tinted
@@ -2226,6 +2344,7 @@ class InstallerApp:
2226
2344
  # Clear all tracking lists
2227
2345
  self.check_vars.clear()
2228
2346
  self.autostart_vars.clear()
2347
+ self.autostart_gears.clear()
2229
2348
  self.skill_vars.clear()
2230
2349
  self.status_labels.clear()
2231
2350
  self.icon_labels.clear()
@@ -2285,8 +2404,6 @@ class InstallerApp:
2285
2404
  for theme_name, theme_data in self.THEMES.items():
2286
2405
  btn = tk.Button(
2287
2406
  theme_frame,
2288
- text=theme_data["emoji"],
2289
- font=("", 14),
2290
2407
  width=2,
2291
2408
  relief="flat",
2292
2409
  bd=0,
@@ -2296,6 +2413,7 @@ class InstallerApp:
2296
2413
  cursor="hand2",
2297
2414
  command=lambda tn=theme_name: self._select_theme(tn)
2298
2415
  )
2416
+ self._set_emoji_face(btn, theme_data["emoji"])
2299
2417
  btn.pack(side="left", padx=2)
2300
2418
  self.theme_buttons[theme_name] = btn
2301
2419
 
@@ -2303,8 +2421,6 @@ class InstallerApp:
2303
2421
  # picked color, persisted per theme; re-clicking the emoji resets.
2304
2422
  accent_btn = tk.Button(
2305
2423
  theme_frame,
2306
- text="🎨",
2307
- font=("", 14),
2308
2424
  width=2,
2309
2425
  relief="flat",
2310
2426
  bd=0,
@@ -2315,6 +2431,7 @@ class InstallerApp:
2315
2431
  cursor="hand2",
2316
2432
  command=self._pick_accent,
2317
2433
  )
2434
+ self._set_emoji_face(accent_btn, "🎨")
2318
2435
  accent_btn.pack(side="left", padx=(8, 2))
2319
2436
  self._attach_tooltip(accent_btn,
2320
2437
  "Re-tint this theme from a base color "
@@ -2636,6 +2753,33 @@ class InstallerApp:
2636
2753
 
2637
2754
  self._update_status_labels()
2638
2755
 
2756
+ def _set_emoji_face(self, button: tk.Button, emoji_char: str, size: int = 20) -> None:
2757
+ """Put an emoji on a button as a color image, falling back to its text.
2758
+
2759
+ Tk draws a character with one foreground color, so an emoji set as text
2760
+ comes out as the monochrome glyph. Rendering it through PIL keeps the
2761
+ color, which is the whole point of the theme and accent buttons.
2762
+ """
2763
+ photo = None
2764
+ try:
2765
+ cache_file = os.path.join(
2766
+ _get_emoji_cache_dir(), f"btn_{ord(emoji_char[0]):x}_{size}.png"
2767
+ )
2768
+ if not os.path.exists(cache_file):
2769
+ render_emoji_icon(emoji_char, cache_file, size=size)
2770
+ if os.path.exists(cache_file):
2771
+ photo = ImageTk.PhotoImage(file=cache_file)
2772
+ except Exception:
2773
+ photo = None
2774
+
2775
+ if photo is not None:
2776
+ # The cache keeps the only reference; without it Tk shows a blank
2777
+ # button as soon as the image is collected.
2778
+ self.icon_cache[f"emoji_{emoji_char}_{size}"] = photo
2779
+ button.config(image=photo, text="", width=size + 8, height=size + 4)
2780
+ else:
2781
+ button.config(text=emoji_char, font=("", 14), width=2)
2782
+
2639
2783
  def _get_tool_icon(self, icon_name_or_path: str) -> Optional[ImageTk.PhotoImage]:
2640
2784
  """Get or load a tool icon, using cache to prevent garbage collection."""
2641
2785
  if icon_name_or_path in self.icon_cache:
@@ -2657,6 +2801,160 @@ class InstallerApp:
2657
2801
  return custom_path, self._get_tool_icon(custom_path)
2658
2802
  return tool.icon, self._get_tool_icon(tool.icon)
2659
2803
 
2804
+
2805
+ def _show_autostart_conditions_dialog(self, tool_key: str):
2806
+ """Edit when a tool may autostart — the ⚙ beside its Auto-Start box.
2807
+
2808
+ Shows only the conditions the tool advertises. Values are stored per
2809
+ host in the installer's autostart.json, never in the tool's repo.
2810
+ """
2811
+ tool = self.tools_by_key.get(tool_key)
2812
+ if not tool or not tool.autostart_conditions:
2813
+ return
2814
+
2815
+ t = self.theme
2816
+ bg, fg, accent, muted = t["bg"], t["fg"], t["accent"], t["muted"]
2817
+ input_bg = t.get("canvas_bg", bg)
2818
+
2819
+ saved = get_autostart_conditions(tool)
2820
+
2821
+ dialog = tk.Toplevel(self.root)
2822
+ dialog.title(f"Auto-Start conditions — {tool.name}")
2823
+ dialog.configure(background=bg)
2824
+ dialog.transient(self.root)
2825
+ dialog.resizable(False, False)
2826
+
2827
+ body = tk.Frame(dialog, bg=bg, padx=18, pady=16)
2828
+ body.pack(fill=tk.BOTH, expand=True)
2829
+
2830
+ tk.Label(body, text=tool.name, bg=bg, fg=fg,
2831
+ font=("", 12, "bold")).pack(anchor="w")
2832
+ tk.Label(body, text="Start at login only when all ticked conditions hold.",
2833
+ bg=bg, fg=muted, font=("", 9)).pack(anchor="w", pady=(0, 12))
2834
+
2835
+ # --- time_window -------------------------------------------------
2836
+ window_enabled = tk.BooleanVar(value="time_window" in saved)
2837
+ from_var = tk.StringVar(value=saved.get("time_window", {}).get("from", "06:00"))
2838
+ to_var = tk.StringVar(value=saved.get("time_window", {}).get("to", "12:00"))
2839
+
2840
+ if "time_window" in tool.autostart_conditions:
2841
+ box = tk.Frame(body, bg=bg)
2842
+ box.pack(fill="x", anchor="w", pady=(0, 10))
2843
+ tk.Checkbutton(box, text="Only within a time window", variable=window_enabled,
2844
+ bg=bg, fg=fg, selectcolor=input_bg, activebackground=bg,
2845
+ activeforeground=fg, highlightthickness=0,
2846
+ font=("", 10)).pack(anchor="w")
2847
+ row = tk.Frame(box, bg=bg)
2848
+ row.pack(anchor="w", padx=(24, 0), pady=(2, 0))
2849
+ tk.Label(row, text="from", bg=bg, fg=muted).pack(side="left")
2850
+ tk.Entry(row, textvariable=from_var, width=7, bg=input_bg, fg=fg,
2851
+ insertbackground=fg, justify="center").pack(side="left", padx=4)
2852
+ tk.Label(row, text="to", bg=bg, fg=muted).pack(side="left")
2853
+ tk.Entry(row, textvariable=to_var, width=7, bg=input_bg, fg=fg,
2854
+ insertbackground=fg, justify="center").pack(side="left", padx=4)
2855
+ tk.Label(row, text="(24h, e.g. 06:00)", bg=bg, fg=muted,
2856
+ font=("", 8)).pack(side="left", padx=(6, 0))
2857
+
2858
+ # --- network -----------------------------------------------------
2859
+ net_enabled = tk.BooleanVar(value="network" in saved)
2860
+ ssid_var = tk.StringVar(
2861
+ value=", ".join(saved.get("network", {}).get("ssids", [])))
2862
+ grace_var = tk.StringVar(
2863
+ value=str(saved.get("network", {}).get("grace_seconds", 120)))
2864
+
2865
+ if "network" in tool.autostart_conditions:
2866
+ box = tk.Frame(body, bg=bg)
2867
+ box.pack(fill="x", anchor="w", pady=(0, 10))
2868
+ tk.Checkbutton(box, text="Only on these Wi-Fi networks", variable=net_enabled,
2869
+ bg=bg, fg=fg, selectcolor=input_bg, activebackground=bg,
2870
+ activeforeground=fg, highlightthickness=0,
2871
+ font=("", 10)).pack(anchor="w")
2872
+ row = tk.Frame(box, bg=bg)
2873
+ row.pack(anchor="w", fill="x", padx=(24, 0), pady=(2, 0))
2874
+ tk.Entry(row, textvariable=ssid_var, width=34, bg=input_bg, fg=fg,
2875
+ insertbackground=fg).pack(side="left")
2876
+ tk.Label(row, text="SSIDs, comma-separated", bg=bg, fg=muted,
2877
+ font=("", 8)).pack(side="left", padx=(6, 0))
2878
+
2879
+ row2 = tk.Frame(box, bg=bg)
2880
+ row2.pack(anchor="w", padx=(24, 0), pady=(4, 0))
2881
+ tk.Label(row2, text="wait up to", bg=bg, fg=muted).pack(side="left")
2882
+ tk.Entry(row2, textvariable=grace_var, width=5, bg=input_bg, fg=fg,
2883
+ insertbackground=fg, justify="center").pack(side="left", padx=4)
2884
+ tk.Label(row2, text="s after login for the network to connect",
2885
+ bg=bg, fg=muted).pack(side="left")
2886
+
2887
+ current = current_ssids()
2888
+ if current:
2889
+ hint = tk.Label(box, text=f"connected now: {', '.join(current)}",
2890
+ bg=bg, fg=accent, font=("", 8), cursor="hand2")
2891
+ hint.pack(anchor="w", padx=(24, 0), pady=(3, 0))
2892
+ hint.bind("<Button-1>",
2893
+ lambda _e: ssid_var.set(", ".join(current)))
2894
+
2895
+ status = tk.Label(body, text="", bg=bg, fg=muted, font=("", 9))
2896
+ status.pack(anchor="w", pady=(4, 0))
2897
+
2898
+ def collect() -> Optional[dict]:
2899
+ """Build the conditions dict, or None when the input is invalid."""
2900
+ out = {}
2901
+ if "time_window" in tool.autostart_conditions and window_enabled.get():
2902
+ start, end = from_var.get().strip(), to_var.get().strip()
2903
+ if _parse_hhmm(start) is None or _parse_hhmm(end) is None:
2904
+ status.config(text="Times must look like 06:00.", fg="#e06c75")
2905
+ return None
2906
+ out["time_window"] = {"from": start, "to": end}
2907
+ if "network" in tool.autostart_conditions and net_enabled.get():
2908
+ ssids = [s.strip() for s in ssid_var.get().split(",") if s.strip()]
2909
+ if not ssids:
2910
+ status.config(text="Name at least one SSID.", fg="#e06c75")
2911
+ return None
2912
+ try:
2913
+ grace = max(0, int(float(grace_var.get().strip() or 0)))
2914
+ except ValueError:
2915
+ status.config(text="The wait must be a number of seconds.",
2916
+ fg="#e06c75")
2917
+ return None
2918
+ out["network"] = {"ssids": ssids, "grace_seconds": grace}
2919
+ return out
2920
+
2921
+ def on_save():
2922
+ conditions = collect()
2923
+ if conditions is None:
2924
+ return
2925
+ set_autostart_conditions(tool, conditions)
2926
+ self._refresh_autostart_gear(tool_key)
2927
+ dialog.destroy()
2928
+
2929
+ buttons = tk.Frame(body, bg=bg)
2930
+ buttons.pack(fill="x", pady=(14, 0))
2931
+ tk.Button(buttons, text="Save", command=on_save, bg=accent, fg=bg,
2932
+ relief="flat", padx=16, pady=4,
2933
+ activebackground=accent).pack(side="right")
2934
+ tk.Button(buttons, text="Cancel", command=dialog.destroy, bg=input_bg,
2935
+ fg=fg, relief="flat", padx=12, pady=4,
2936
+ activebackground=input_bg).pack(side="right", padx=(0, 8))
2937
+
2938
+ dialog.update_idletasks()
2939
+ # Centre on the main window rather than the screen, so it lands where
2940
+ # the user is looking on a multi-monitor desktop.
2941
+ x = self.root.winfo_rootx() + (self.root.winfo_width() - dialog.winfo_width()) // 2
2942
+ y = self.root.winfo_rooty() + (self.root.winfo_height() - dialog.winfo_height()) // 3
2943
+ dialog.geometry(f"+{max(0, x)}+{max(0, y)}")
2944
+ dialog.grab_set()
2945
+
2946
+ def _refresh_autostart_gear(self, tool_key: str) -> None:
2947
+ """Recolor a row's ⚙ so a configured tool reads as configured."""
2948
+ gear = self.autostart_gears.get(tool_key)
2949
+ tool = self.tools_by_key.get(tool_key)
2950
+ if gear is None or tool is None:
2951
+ return
2952
+ style = "CardAccent.TLabel" if get_autostart_conditions(tool) else "CardMuted.TLabel"
2953
+ try:
2954
+ gear.configure(style=style)
2955
+ except tk.TclError:
2956
+ pass
2957
+
2660
2958
  def _show_icon_dialog(self, tool_key: str):
2661
2959
  """Show dialog to customize a tool's icon."""
2662
2960
  tool = self.tools_by_key.get(tool_key)
@@ -4176,8 +4474,22 @@ class InstallerApp:
4176
4474
  autostart_var = tk.BooleanVar(value=autostart_default)
4177
4475
  self.autostart_vars[key] = autostart_var
4178
4476
  autostart_var.trace_add("write", self._on_checkbox_changed)
4179
- autostart_cb = ttk.Checkbutton(autostart_frame, variable=autostart_var, style="Card.TCheckbutton")
4180
- autostart_cb.pack(expand=True)
4477
+ # A tool with conditions gets the checkbox and a ⚙ side by side;
4478
+ # without them the checkbox stays centred as before.
4479
+ if tool.autostart_conditions:
4480
+ holder = ttk.Frame(autostart_frame, style="Card.TFrame")
4481
+ holder.pack(expand=True)
4482
+ autostart_cb = ttk.Checkbutton(holder, variable=autostart_var, style="Card.TCheckbutton")
4483
+ autostart_cb.pack(side="left")
4484
+ gear = ttk.Label(holder, text="\u2699", cursor="hand2", style="CardMuted.TLabel")
4485
+ gear.pack(side="left", padx=(2, 0))
4486
+ gear.bind("<Button-1>",
4487
+ lambda _e, k=key: self._show_autostart_conditions_dialog(k))
4488
+ self.autostart_gears[key] = gear
4489
+ self._refresh_autostart_gear(key)
4490
+ else:
4491
+ autostart_cb = ttk.Checkbutton(autostart_frame, variable=autostart_var, style="Card.TCheckbutton")
4492
+ autostart_cb.pack(expand=True)
4181
4493
 
4182
4494
  # Name row with tag badges
4183
4495
  name_row = ttk.Frame(info_frame, style="Card.TFrame")
@@ -4311,8 +4623,22 @@ class InstallerApp:
4311
4623
  autostart_var = tk.BooleanVar(value=autostart_default)
4312
4624
  self.autostart_vars[key] = autostart_var
4313
4625
  autostart_var.trace_add("write", self._on_checkbox_changed)
4314
- autostart_cb = ttk.Checkbutton(autostart_frame, variable=autostart_var, style="Card.TCheckbutton")
4315
- autostart_cb.pack(expand=True)
4626
+ # A tool with conditions gets the checkbox and a ⚙ side by side;
4627
+ # without them the checkbox stays centred as before.
4628
+ if tool.autostart_conditions:
4629
+ holder = ttk.Frame(autostart_frame, style="Card.TFrame")
4630
+ holder.pack(expand=True)
4631
+ autostart_cb = ttk.Checkbutton(holder, variable=autostart_var, style="Card.TCheckbutton")
4632
+ autostart_cb.pack(side="left")
4633
+ gear = ttk.Label(holder, text="\u2699", cursor="hand2", style="CardMuted.TLabel")
4634
+ gear.pack(side="left", padx=(2, 0))
4635
+ gear.bind("<Button-1>",
4636
+ lambda _e, k=key: self._show_autostart_conditions_dialog(k))
4637
+ self.autostart_gears[key] = gear
4638
+ self._refresh_autostart_gear(key)
4639
+ else:
4640
+ autostart_cb = ttk.Checkbutton(autostart_frame, variable=autostart_var, style="Card.TCheckbutton")
4641
+ autostart_cb.pack(expand=True)
4316
4642
 
4317
4643
  name_row = ttk.Frame(info_frame, style="Card.TFrame")
4318
4644
  name_row.pack(anchor="w", fill="x")
@@ -65,6 +65,11 @@ class ToolMetadata:
65
65
  # cli_tools_kit.skill_status() at advertise time and pass it in.
66
66
  skill_name: Optional[str] = None
67
67
  skill_status: Optional[str] = None
68
+ # Conditional autostart (PROTOCOL.md § Conditional autostart). Names the
69
+ # conditions this tool's autostart supports — "time_window", "network" —
70
+ # and nothing more: the values behind them are the user's, and the
71
+ # installer stores them per host. See cli_tools_kit.autostart_gate.
72
+ autostart_conditions: Optional[list] = None
68
73
 
69
74
 
70
75
  class ToolInstaller:
@@ -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.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
@@ -4,6 +4,7 @@ pyproject.toml
4
4
  cli_tools_kit/__init__.py
5
5
  cli_tools_kit/__main__.py
6
6
  cli_tools_kit/advertise.py
7
+ cli_tools_kit/autostart_gate.py
7
8
  cli_tools_kit/cron_installer.py
8
9
  cli_tools_kit/gui_installer.py
9
10
  cli_tools_kit/host.py
@@ -27,6 +28,7 @@ cli_tools_kit/taxonomy/corpus.py
27
28
  cli_tools_kit/taxonomy/embedder.py
28
29
  cli_tools_kit/taxonomy/groups.py
29
30
  cli_tools_kit/taxonomy/llm_groups.py
31
+ tests/test_autostart_gate.py
30
32
  tests/test_capability_groups.py
31
33
  tests/test_cron_installer.py
32
34
  tests/test_host.py
@@ -1,3 +1,4 @@
1
1
  [console_scripts]
2
2
  cli-tool-installer = cli_tools_kit.gui_installer:run
3
+ cli-tools-kit-autostart-gate = cli_tools_kit.autostart_gate:main
3
4
  cli-tools-kit-setup = cli_tools_kit.onboarding:main
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cli-tools-kit"
7
- version = "0.7.1"
7
+ version = "0.8.1"
8
8
  description = "Installer protocol + helpers for self-installing Python CLI/GUI tools (desktop shortcuts, bash aliases, cron entries), plus reusable tkinter and curses installer screens"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -49,6 +49,9 @@ cli-tool-installer = "cli_tools_kit.gui_installer:run"
49
49
  # First-run setup for an organisation adopting the kit. Same thing as
50
50
  # `python3 -m cli_tools_kit`.
51
51
  cli-tools-kit-setup = "cli_tools_kit.onboarding:main"
52
+ # Wrapper an autostart .desktop Exec line calls to evaluate a tool's
53
+ # conditions before launching it. See cli_tools_kit.autostart_gate.
54
+ cli-tools-kit-autostart-gate = "cli_tools_kit.autostart_gate:main"
52
55
 
53
56
  [project.urls]
54
57
  Homepage = "https://github.com/Probst1nator/cli-tools-kit"
@@ -0,0 +1,236 @@
1
+ """Tests for autostart_gate — time-window and network conditions on autostart."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ from cli_tools_kit import ToolMetadata, advertise
12
+ from cli_tools_kit import autostart_gate as gate
13
+
14
+
15
+ # ------------------------------------------------------------- time_window
16
+
17
+ @pytest.mark.parametrize("hour,expected", [
18
+ (5, False), (6, True), (9, True), (11, True), (12, False), (23, False),
19
+ ])
20
+ def test_time_window_half_open(hour: int, expected: bool) -> None:
21
+ """06:00-12:00 includes its start and excludes its end."""
22
+ window = {"from": "06:00", "to": "12:00"}
23
+ now = datetime(2026, 9, 16, hour, 0)
24
+ assert gate.time_window_open(window, now) is expected
25
+
26
+
27
+ @pytest.mark.parametrize("hour,expected", [
28
+ (21, False), (22, True), (23, True), (0, True), (1, True), (2, False),
29
+ ])
30
+ def test_time_window_wraps_midnight(hour: int, expected: bool) -> None:
31
+ """An end not after the start reads as a window across midnight."""
32
+ window = {"from": "22:00", "to": "02:00"}
33
+ now = datetime(2026, 9, 16, hour, 0)
34
+ assert gate.time_window_open(window, now) is expected
35
+
36
+
37
+ def test_time_window_unparseable_does_not_restrict() -> None:
38
+ """A malformed window must not silently disable a tool forever."""
39
+ assert gate.time_window_open({"from": "oops", "to": "12:00"}) is True
40
+ assert gate.time_window_open({}) is True
41
+ assert gate.time_window_open({"from": "06:00", "to": "06:00"}) is True
42
+
43
+
44
+ # ----------------------------------------------------------------- network
45
+
46
+ def test_network_matches_active_ssid(monkeypatch: pytest.MonkeyPatch) -> None:
47
+ monkeypatch.setattr(gate, "current_ssids", lambda: ["b_wifi"])
48
+ assert gate.network_matches({"ssids": ["b_wifi", "b2_wifi"]}, wait=False) is True
49
+
50
+
51
+ def test_network_rejects_other_ssid(monkeypatch: pytest.MonkeyPatch) -> None:
52
+ monkeypatch.setattr(gate, "current_ssids", lambda: ["cafe_guest"])
53
+ assert gate.network_matches({"ssids": ["b_wifi"]}, wait=False) is False
54
+
55
+
56
+ def test_network_empty_list_does_not_restrict(monkeypatch: pytest.MonkeyPatch) -> None:
57
+ monkeypatch.setattr(gate, "current_ssids", lambda: [])
58
+ assert gate.network_matches({"ssids": []}, wait=False) is True
59
+
60
+
61
+ def test_network_waits_for_late_association(monkeypatch: pytest.MonkeyPatch) -> None:
62
+ """The link usually associates after login, so the gate polls for it."""
63
+ seen = {"calls": 0}
64
+
65
+ def ssids():
66
+ seen["calls"] += 1
67
+ return ["b_wifi"] if seen["calls"] >= 3 else []
68
+
69
+ monkeypatch.setattr(gate, "current_ssids", ssids)
70
+ monkeypatch.setattr(gate.time, "sleep", lambda _s: None)
71
+ assert gate.network_matches({"ssids": ["b_wifi"], "grace_seconds": 60}) is True
72
+ assert seen["calls"] >= 3
73
+
74
+
75
+ def test_network_gives_up_after_grace(monkeypatch: pytest.MonkeyPatch) -> None:
76
+ monkeypatch.setattr(gate, "current_ssids", lambda: [])
77
+ monkeypatch.setattr(gate.time, "sleep", lambda _s: None)
78
+ assert gate.network_matches({"ssids": ["b_wifi"], "grace_seconds": 1}) is False
79
+
80
+
81
+ def test_current_ssids_reads_active_only(monkeypatch: pytest.MonkeyPatch) -> None:
82
+ class Result:
83
+ returncode = 0
84
+ stdout = "no:neighbour_wifi\nyes:b_wifi\nno:other\n"
85
+
86
+ monkeypatch.setattr(gate.subprocess, "run", lambda *a, **k: Result())
87
+ assert gate.current_ssids() == ["b_wifi"]
88
+
89
+
90
+ def test_current_ssids_survives_missing_nmcli(monkeypatch: pytest.MonkeyPatch) -> None:
91
+ def boom(*_a, **_k):
92
+ raise FileNotFoundError("nmcli")
93
+
94
+ monkeypatch.setattr(gate.subprocess, "run", boom)
95
+ assert gate.current_ssids() == []
96
+
97
+
98
+ # -------------------------------------------------------------- evaluation
99
+
100
+ def test_conditions_pass_with_nothing_configured() -> None:
101
+ assert gate.conditions_pass({}) == (True, "")
102
+
103
+
104
+ def test_conditions_report_the_failing_one(monkeypatch: pytest.MonkeyPatch) -> None:
105
+ monkeypatch.setattr(gate, "time_window_open", lambda *_a, **_k: False)
106
+ passed, reason = gate.conditions_pass({"time_window": {"from": "06:00", "to": "12:00"}})
107
+ assert passed is False
108
+ assert "06:00" in reason
109
+
110
+
111
+ def test_unknown_condition_is_skipped_not_failed() -> None:
112
+ """Config from a newer kit must not shut the gate on an older one."""
113
+ assert gate.conditions_pass({"phase_of_moon": {"full": True}}) == (True, "")
114
+
115
+
116
+ # ------------------------------------------------------------------ config
117
+
118
+ def test_legacy_slug_uses_the_installers_own_config_dir(
119
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
120
+ """The legacy identity overrides config_dir, and the gate must follow it.
121
+
122
+ Deriving the path from the slug alone would look in ~/.config/probable.work
123
+ while the installer writes ~/.config/tools-installer, so every gated tool
124
+ would read an empty config and start unconditionally.
125
+ """
126
+ from cli_tools_kit.identity import LEGACY_IDENTITY
127
+
128
+ monkeypatch.setenv("HOME", str(tmp_path))
129
+ assert gate.config_path(LEGACY_IDENTITY.slug) == os.path.join(
130
+ LEGACY_IDENTITY.config_path, "autostart.json")
131
+ assert "tools-installer" in gate.config_path(LEGACY_IDENTITY.slug)
132
+
133
+
134
+ def test_conditions_round_trip(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
135
+ monkeypatch.setenv("HOME", str(tmp_path))
136
+ conditions = {"network": {"ssids": ["b_wifi"], "grace_seconds": 120}}
137
+ gate.save_tool_conditions("acme-tools", "jarvis", conditions)
138
+ assert gate.load_tool_conditions("acme-tools", "jarvis") == conditions
139
+
140
+
141
+ def test_conditions_are_per_tool(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
142
+ monkeypatch.setenv("HOME", str(tmp_path))
143
+ gate.save_tool_conditions("acme-tools", "jarvis", {"network": {"ssids": ["b_wifi"]}})
144
+ gate.save_tool_conditions("acme-tools", "lernclaude",
145
+ {"time_window": {"from": "06:00", "to": "12:00"}})
146
+ assert gate.load_tool_conditions("acme-tools", "jarvis") == {
147
+ "network": {"ssids": ["b_wifi"]}}
148
+ assert gate.load_tool_conditions("acme-tools", "lernclaude") == {
149
+ "time_window": {"from": "06:00", "to": "12:00"}}
150
+
151
+
152
+ def test_saving_empty_clears_a_tool(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
153
+ monkeypatch.setenv("HOME", str(tmp_path))
154
+ gate.save_tool_conditions("acme-tools", "jarvis", {"network": {"ssids": ["b_wifi"]}})
155
+ gate.save_tool_conditions("acme-tools", "jarvis", {})
156
+ assert gate.load_tool_conditions("acme-tools", "jarvis") == {}
157
+
158
+
159
+ def test_missing_config_reads_as_no_conditions(tmp_path: Path,
160
+ monkeypatch: pytest.MonkeyPatch) -> None:
161
+ monkeypatch.setenv("HOME", str(tmp_path))
162
+ assert gate.load_tool_conditions("acme-tools", "nothing-here") == {}
163
+
164
+
165
+ def test_corrupt_config_gates_open(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
166
+ """An unreadable config must not stop every gated tool from starting."""
167
+ monkeypatch.setenv("HOME", str(tmp_path))
168
+ path = Path(gate.config_path("acme-tools"))
169
+ path.parent.mkdir(parents=True, exist_ok=True)
170
+ path.write_text("{not json")
171
+ assert gate.load_tool_conditions("acme-tools", "jarvis") == {}
172
+
173
+
174
+ # ------------------------------------------------------------------- entry
175
+
176
+ def test_closed_gate_exits_zero(tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
177
+ capsys: pytest.CaptureFixture) -> None:
178
+ """A non-zero exit would surface as a failed systemd unit every login."""
179
+ monkeypatch.setenv("HOME", str(tmp_path))
180
+ gate.save_tool_conditions("acme-tools", "lernclaude",
181
+ {"time_window": {"from": "06:00", "to": "06:01"}})
182
+ monkeypatch.setattr(gate, "time_window_open", lambda *_a, **_k: False)
183
+ rc = gate.main(["--slug", "acme-tools", "--tool", "lernclaude", "--", "/bin/true"])
184
+ assert rc == 0
185
+ assert "not started" in capsys.readouterr().err
186
+
187
+
188
+ def test_open_gate_execs_the_command(tmp_path: Path,
189
+ monkeypatch: pytest.MonkeyPatch) -> None:
190
+ monkeypatch.setenv("HOME", str(tmp_path))
191
+ execed = {}
192
+
193
+ def fake_exec(binary, argv):
194
+ execed["binary"] = binary
195
+ execed["argv"] = list(argv)
196
+ raise SystemExit(0)
197
+
198
+ monkeypatch.setattr(gate.os, "execvp", fake_exec)
199
+ with pytest.raises(SystemExit):
200
+ gate.main(["--slug", "acme-tools", "--tool", "anything",
201
+ "--", "/usr/bin/python3", "main.py"])
202
+ assert execed["binary"] == "/usr/bin/python3"
203
+ assert execed["argv"] == ["/usr/bin/python3", "main.py"]
204
+
205
+
206
+ def test_check_reports_without_running(tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
207
+ capsys: pytest.CaptureFixture) -> None:
208
+ monkeypatch.setenv("HOME", str(tmp_path))
209
+ rc = gate.main(["--slug", "acme-tools", "--tool", "unconfigured", "--check"])
210
+ assert rc == 0
211
+ assert "open" in capsys.readouterr().out
212
+
213
+
214
+ # --------------------------------------------------------------- advertise
215
+
216
+ def test_advertise_emits_conditions(capsys: pytest.CaptureFixture) -> None:
217
+ import json
218
+
219
+ with pytest.raises(SystemExit):
220
+ advertise(ToolMetadata(
221
+ name="Jarvis", desktop_file="jarvis.desktop", icon="mic",
222
+ desc="Voice assistant", autostart_conditions=["network"],
223
+ ))
224
+ record = json.loads(capsys.readouterr().out)[0]
225
+ assert record["autostart_conditions"] == ["network"]
226
+
227
+
228
+ def test_advertise_omits_conditions_when_unset(capsys: pytest.CaptureFixture) -> None:
229
+ """A tool that never touched the field emits a pre-0.8.0 record."""
230
+ import json
231
+
232
+ with pytest.raises(SystemExit):
233
+ advertise(ToolMetadata(
234
+ name="Plain", desktop_file="plain.desktop", icon="x", desc="y",
235
+ ))
236
+ assert "autostart_conditions" not in json.loads(capsys.readouterr().out)[0]
File without changes
File without changes
File without changes