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.
- {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/PKG-INFO +1 -1
- {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit/__init__.py +1 -1
- {cli_tools_kit-0.7.1 → 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.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit/gui_installer.py +342 -16
- {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit/tool_installer.py +5 -0
- {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit.egg-info/PKG-INFO +1 -1
- {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit.egg-info/SOURCES.txt +2 -0
- {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit.egg-info/entry_points.txt +1 -0
- {cli_tools_kit-0.7.1 → 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.7.1 → cli_tools_kit-0.8.1}/LICENSE +0 -0
- {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/README.md +0 -0
- {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit/__main__.py +0 -0
- {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit/cron_installer.py +0 -0
- {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit/host.py +0 -0
- {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit/identity.py +0 -0
- {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit/onboarding.py +0 -0
- {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit/skills.py +0 -0
- {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit/sources.py +0 -0
- {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit/taxonomy/__init__.py +0 -0
- {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit/taxonomy/build.py +0 -0
- {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit/taxonomy/capability.py +0 -0
- {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit/taxonomy/cluster.py +0 -0
- {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit/taxonomy/corpus.py +0 -0
- {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit/taxonomy/embedder.py +0 -0
- {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit/taxonomy/groups.py +0 -0
- {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit/taxonomy/llm_groups.py +0 -0
- {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit/tui_installer.py +0 -0
- {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit.egg-info/dependency_links.txt +0 -0
- {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit.egg-info/requires.txt +0 -0
- {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/cli_tools_kit.egg-info/top_level.txt +0 -0
- {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/setup.cfg +0 -0
- {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/tests/test_capability_groups.py +0 -0
- {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/tests/test_cron_installer.py +0 -0
- {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/tests/test_host.py +0 -0
- {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/tests/test_identity.py +0 -0
- {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/tests/test_llm_groups.py +0 -0
- {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/tests/test_skills.py +0 -0
- {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/tests/test_sources.py +0 -0
- {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/tests/test_taxonomy_cluster.py +0 -0
- {cli_tools_kit-0.7.1 → cli_tools_kit-0.8.1}/tests/test_tool_installer.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
525
|
-
|
|
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
|
-
|
|
4180
|
-
|
|
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
|
-
|
|
4315
|
-
|
|
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.
|
|
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
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cli-tools-kit"
|
|
7
|
-
version = "0.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|