cli-tools-kit 0.8.4__tar.gz → 1.1.0__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 (60) hide show
  1. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/PKG-INFO +93 -15
  2. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/README.md +88 -13
  3. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/__init__.py +1 -1
  4. cli_tools_kit-1.1.0/cli_tools_kit/autostart.py +284 -0
  5. cli_tools_kit-1.1.0/cli_tools_kit/cli.py +663 -0
  6. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/cron_installer.py +41 -23
  7. cli_tools_kit-1.1.0/cli_tools_kit/discovery.py +294 -0
  8. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/gui_installer.py +381 -2447
  9. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/host.py +68 -1
  10. cli_tools_kit-1.1.0/cli_tools_kit/icons.py +423 -0
  11. cli_tools_kit-1.1.0/cli_tools_kit/install.py +298 -0
  12. cli_tools_kit-1.1.0/cli_tools_kit/settings.py +125 -0
  13. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/sources.py +37 -10
  14. cli_tools_kit-1.1.0/cli_tools_kit/state.py +204 -0
  15. cli_tools_kit-1.1.0/cli_tools_kit/sweep.py +187 -0
  16. cli_tools_kit-1.1.0/cli_tools_kit/testing.py +126 -0
  17. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/tool_installer.py +10 -0
  18. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/tui_installer.py +55 -2
  19. cli_tools_kit-1.1.0/cli_tools_kit/upgrade.py +312 -0
  20. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit.egg-info/PKG-INFO +93 -15
  21. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit.egg-info/SOURCES.txt +18 -1
  22. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/pyproject.toml +10 -3
  23. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/tests/test_cron_installer.py +23 -0
  24. cli_tools_kit-1.1.0/tests/test_e2e.py +164 -0
  25. cli_tools_kit-1.1.0/tests/test_engine.py +182 -0
  26. cli_tools_kit-1.1.0/tests/test_gui_smoke.py +68 -0
  27. cli_tools_kit-1.1.0/tests/test_hooks.py +45 -0
  28. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/tests/test_host.py +42 -0
  29. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/tests/test_identity.py +22 -20
  30. cli_tools_kit-1.1.0/tests/test_public_api.py +117 -0
  31. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/tests/test_sources.py +42 -17
  32. cli_tools_kit-1.1.0/tests/test_testing.py +57 -0
  33. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/tests/test_tool_installer.py +22 -1
  34. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/tests/test_tui_installer.py +21 -2
  35. cli_tools_kit-1.1.0/tests/test_upgrade.py +196 -0
  36. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/LICENSE +0 -0
  37. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/__main__.py +0 -0
  38. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/advertise.py +0 -0
  39. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/autostart_gate.py +0 -0
  40. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/identity.py +0 -0
  41. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/onboarding.py +0 -0
  42. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/skills.py +0 -0
  43. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/taxonomy/__init__.py +0 -0
  44. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/taxonomy/build.py +0 -0
  45. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/taxonomy/capability.py +0 -0
  46. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/taxonomy/cluster.py +0 -0
  47. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/taxonomy/corpus.py +0 -0
  48. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/taxonomy/embedder.py +0 -0
  49. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/taxonomy/groups.py +0 -0
  50. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/taxonomy/llm_groups.py +0 -0
  51. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit.egg-info/dependency_links.txt +0 -0
  52. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit.egg-info/entry_points.txt +0 -0
  53. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit.egg-info/requires.txt +0 -0
  54. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit.egg-info/top_level.txt +0 -0
  55. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/setup.cfg +0 -0
  56. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/tests/test_autostart_gate.py +0 -0
  57. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/tests/test_capability_groups.py +0 -0
  58. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/tests/test_llm_groups.py +0 -0
  59. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/tests/test_skills.py +0 -0
  60. {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/tests/test_taxonomy_cluster.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cli-tools-kit
3
- Version: 0.8.4
3
+ Version: 1.1.0
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
@@ -26,18 +26,21 @@ License: MIT License
26
26
  SOFTWARE.
27
27
 
28
28
  Project-URL: Homepage, https://github.com/Probst1nator/cli-tools-kit
29
+ Project-URL: Changelog, https://github.com/Probst1nator/cli-tools-kit/blob/main/CHANGELOG.md
29
30
  Project-URL: Issues, https://github.com/Probst1nator/cli-tools-kit/issues
30
- Keywords: installer,desktop,cron,cli,linux,kde,gnome
31
+ Keywords: installer,desktop,cron,cli,linux,windows,kde,gnome
31
32
  Classifier: Development Status :: 4 - Beta
32
33
  Classifier: Environment :: Console
33
34
  Classifier: Intended Audience :: Developers
34
35
  Classifier: License :: OSI Approved :: MIT License
35
36
  Classifier: Operating System :: POSIX :: Linux
37
+ Classifier: Operating System :: Microsoft :: Windows
36
38
  Classifier: Programming Language :: Python :: 3
37
39
  Classifier: Programming Language :: Python :: 3 :: Only
38
40
  Classifier: Programming Language :: Python :: 3.10
39
41
  Classifier: Programming Language :: Python :: 3.11
40
42
  Classifier: Programming Language :: Python :: 3.12
43
+ Classifier: Programming Language :: Python :: 3.13
41
44
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
42
45
  Classifier: Topic :: System :: Installation/Setup
43
46
  Requires-Python: >=3.10
@@ -92,17 +95,42 @@ See [`PROTOCOL.md`](PROTOCOL.md) for the full `--advertise` specification.
92
95
  pip install cli-tools-kit
93
96
  ```
94
97
 
95
- Or pin in `requirements.txt`:
98
+ Or pin a range in `requirements.txt`:
96
99
 
97
100
  ```
98
- cli-tools-kit==0.6.0
101
+ cli-tools-kit>=1.0,<2
99
102
  ```
100
103
 
104
+ Pin a range, not an exact version. An installer adds the kit to each tool's
105
+ own `pip install -r requirements.txt`, and two exact pins of one package on
106
+ one pip command line (`==0.6.0` from the tool, `==0.6.3` from the installer)
107
+ can never resolve. Two overlapping ranges always do.
108
+
101
109
  The git URL form still works if you need an unreleased commit:
102
- `pip install git+https://github.com/Probst1nator/cli-tools-kit.git@v0.6.0`.
110
+ `pip install git+https://github.com/Probst1nator/cli-tools-kit.git@v1.0.0`.
111
+
112
+ Requires Python ≥ 3.10 and `termcolor` (installed with the package). Linux
113
+ and Windows are tested in CI.
114
+
115
+ ## Stability
116
+
117
+ From 1.0.0 the kit follows [Semantic Versioning](https://semver.org/). The
118
+ public surface is what `tests/test_public_api.py` pins: the names in
119
+ `cli_tools_kit.__all__`, `ToolInstaller`/`ToolMetadata`/`CronInstaller` and
120
+ their methods, `gui_installer.run()` and its keywords, `InstallHooks`,
121
+ `sources.run_installer()`, `tui_installer.SkillTarget`,
122
+ `testing.assert_advertises()`, the taxonomy
123
+ functions a tree's regroup script calls, the installer's command-line flags,
124
+ and the `--advertise` JSON in [`PROTOCOL.md`](PROTOCOL.md).
103
125
 
104
- Requires Python ≥ 3.10. Optional runtime dep: `termcolor` (colored
105
- install/remove output; falls back to plain text if absent).
126
+ - Removing or renaming anything there is a major release.
127
+ - A name that is going away warns for at least one minor release first.
128
+ - The `--advertise` schema only gains optional fields; a parent installer
129
+ ignores fields it does not know.
130
+ - Anything with a leading underscore, and every module-level name not listed
131
+ above, can change in any release.
132
+
133
+ Changes are listed in [`CHANGELOG.md`](CHANGELOG.md).
106
134
 
107
135
  ## Minimal example
108
136
 
@@ -276,23 +304,53 @@ Keyword-only; every argument defaults to `None`, meaning "leave the default".
276
304
  | `wm_class` | identity's | `StartupWMClass` for window-manager grouping. |
277
305
  | `notify_app` | identity's | `notify-send` application label on the `--check` path. |
278
306
  | `autostart_check_desktop_name`, `check_log_name`, `check_state_name` | identity's | Login-check artifact filenames. |
307
+ | `hooks` | `None` | `InstallHooks(install_tool=…, remove_tool=…, install_skill=…, uninstall_skill=…)`: replace how one tool is installed, for a wrapper that builds a venv per tool. A field left `None` keeps the kit's own. |
308
+ | `upgrade_repos` | `[]` | `(name, path)` of the tool repos an upgrade may pull. `sources.run_installer` fills it with the repos it cloned. See "Upgrades" below. |
279
309
 
280
310
  The identity is applied first and these individual names override it, so you can
281
311
  take the whole namespace from a slug and still change one thing.
282
312
 
283
313
  `run()` owns its own `argparse` and consumes `sys.argv`: `--list`, `--check`,
284
- `--enable-autostart-check`, `--install`, `--update-all`, `--cleanup`, `--tui`,
285
- `--gui`, and a screen when given none of them. A wrapper that needs its own
314
+ `--enable-autostart-check`, `--install`, `--update-all`, `--upgrade`, `--cleanup`,
315
+ `--tui`, `--gui`, and a screen when given none of them. A wrapper that needs its own
286
316
  subcommands should skip `run()` and call the primitives (`discover_tools`,
287
317
  `install_tool`, `remove_tool`, `cli_check`) after applying an identity with
288
318
  `_apply_identity`.
289
319
 
320
+ ### Upgrades
321
+
322
+ When the window or the text screen opens, the installer checks in the
323
+ background whether anything it runs is out of date:
324
+
325
+ - the installer's own checkout (the git repo `entry_script` is in) is behind
326
+ its upstream;
327
+ - a tool repo in `upgrade_repos` is behind its upstream;
328
+ - pip would install a newer cli-tools-kit within the installer's pin. The pin is
329
+ the `requirements.txt` next to `entry_script` when it names the kit, else
330
+ anything below the next major version.
331
+
332
+ If something is, a strip above the table lists it with an Upgrade button (on
333
+ the text screen, a log line and the `u` key). Upgrade pulls the repos with `git
334
+ pull --ff-only`, runs `pip install --upgrade` for the kit, reinstalls the
335
+ installed tools of each pulled repo, and starts the installer again with its
336
+ original command line so the new code is loaded. `--upgrade` does the same
337
+ from a script, checking at once and without the restart.
338
+
339
+ The network is used at most once a day: one `git fetch` per repo and one `pip
340
+ install --dry-run`, cached under the identity's cache directory. The
341
+ comparison with what is on disk runs on every start, so a repo pulled by hand
342
+ stops showing at once. Git never asks for a password here; a private repo
343
+ without stored credentials is skipped. The kit is left alone when it runs from
344
+ a development checkout or outside a virtual environment. `--check` never runs
345
+ any of this, so the login check stays network-free.
346
+
290
347
  ### The text screen
291
348
 
292
349
  Without a display (`DISPLAY`/`WAYLAND_DISPLAY` unset: SSH, WSL, a server) or
293
350
  without `python3-tk`, `run()` opens a curses screen instead of the tkinter
294
351
  window; `--tui` and `--gui` force either. Same rows, same Apply: `Space` ticks
295
- Install, `s` ticks Skill, `a`/`n` tick all or none, `Enter` applies, `q` quits.
352
+ Install, `s` ticks Skill, `a`/`n` tick all or none, `Enter` applies, `u`
353
+ upgrades when an upgrade is offered, `q` quits.
296
354
  On a host where none of the tools is installed yet every row starts ticked.
297
355
 
298
356
  A skill can go to more than one place. The default target writes
@@ -380,7 +438,7 @@ and needs no network.
380
438
  Icon thumbnails need Pillow:
381
439
 
382
440
  ```bash
383
- pip install "cli-tools-kit[gui]==0.6.0"
441
+ pip install "cli-tools-kit[gui]>=1.0,<2"
384
442
  ```
385
443
 
386
444
  Installing the package also exposes a `cli-tool-installer` console script.
@@ -473,8 +531,8 @@ full rather than shallow (a tool that stamps its output with its commit needs th
473
531
  history), and nothing is cloned into a root that does not exist or cannot be
474
532
  written to. A clone that fails prints one line and that source is dropped, so a
475
533
  colleague without access to a private repo still gets everybody else's tools.
476
- `--refresh` brings the clones up to date with `git pull --ff-only`; a checkout
477
- given by `path` is never pulled. Cloning happens in the engine's `pre_discovery`
534
+ `--refresh` and the upgrade (see "Upgrades") bring the clones up to date with
535
+ `git pull --ff-only`; a checkout given by `path` is never pulled. Cloning happens in the engine's `pre_discovery`
478
536
  hook, which `--check` skips, so the login check stays network-free.
479
537
 
480
538
  An `org` entry is the only thing in this module that reaches anything but git,
@@ -532,13 +590,33 @@ per repo it could not (`log=` takes any callable, `print` by default). Pass
532
590
  Reading the TOML needs Python 3.11 or the `tomli` package, which is a dependency
533
591
  on 3.10.
534
592
 
593
+ ## Testing a tool's `--advertise`
594
+
595
+ A tool that breaks the protocol is skipped by the parent installer and simply
596
+ disappears from the list. Put this in the tool's own tests to catch that:
597
+
598
+ ```python
599
+ from cli_tools_kit.testing import assert_advertises
600
+
601
+ def test_advertise():
602
+ assert_advertises("main.py")
603
+ ```
604
+
605
+ It runs the probe the way a parent does (5 s limit, only JSON on stdout) and
606
+ fails with every problem it finds.
607
+
535
608
  ## Tests
536
609
 
537
610
  ```bash
538
- pip install -e ".[dev]"
539
- pytest
611
+ pip install -e ".[dev,gui]"
612
+ ruff check .
613
+ pytest # xvfb-run -a pytest on a host without a display
540
614
  ```
541
615
 
616
+ CI runs the same on Linux and Windows for Python 3.10 to 3.13, plus an
617
+ install from the built wheel. The GUI smoke test skips where tkinter or a
618
+ display is missing.
619
+
542
620
  ## Used by
543
621
 
544
622
  Consumers, each a self-installing tool that answers `--advertise`:
@@ -35,17 +35,42 @@ See [`PROTOCOL.md`](PROTOCOL.md) for the full `--advertise` specification.
35
35
  pip install cli-tools-kit
36
36
  ```
37
37
 
38
- Or pin in `requirements.txt`:
38
+ Or pin a range in `requirements.txt`:
39
39
 
40
40
  ```
41
- cli-tools-kit==0.6.0
41
+ cli-tools-kit>=1.0,<2
42
42
  ```
43
43
 
44
+ Pin a range, not an exact version. An installer adds the kit to each tool's
45
+ own `pip install -r requirements.txt`, and two exact pins of one package on
46
+ one pip command line (`==0.6.0` from the tool, `==0.6.3` from the installer)
47
+ can never resolve. Two overlapping ranges always do.
48
+
44
49
  The git URL form still works if you need an unreleased commit:
45
- `pip install git+https://github.com/Probst1nator/cli-tools-kit.git@v0.6.0`.
50
+ `pip install git+https://github.com/Probst1nator/cli-tools-kit.git@v1.0.0`.
51
+
52
+ Requires Python ≥ 3.10 and `termcolor` (installed with the package). Linux
53
+ and Windows are tested in CI.
54
+
55
+ ## Stability
56
+
57
+ From 1.0.0 the kit follows [Semantic Versioning](https://semver.org/). The
58
+ public surface is what `tests/test_public_api.py` pins: the names in
59
+ `cli_tools_kit.__all__`, `ToolInstaller`/`ToolMetadata`/`CronInstaller` and
60
+ their methods, `gui_installer.run()` and its keywords, `InstallHooks`,
61
+ `sources.run_installer()`, `tui_installer.SkillTarget`,
62
+ `testing.assert_advertises()`, the taxonomy
63
+ functions a tree's regroup script calls, the installer's command-line flags,
64
+ and the `--advertise` JSON in [`PROTOCOL.md`](PROTOCOL.md).
46
65
 
47
- Requires Python ≥ 3.10. Optional runtime dep: `termcolor` (colored
48
- install/remove output; falls back to plain text if absent).
66
+ - Removing or renaming anything there is a major release.
67
+ - A name that is going away warns for at least one minor release first.
68
+ - The `--advertise` schema only gains optional fields; a parent installer
69
+ ignores fields it does not know.
70
+ - Anything with a leading underscore, and every module-level name not listed
71
+ above, can change in any release.
72
+
73
+ Changes are listed in [`CHANGELOG.md`](CHANGELOG.md).
49
74
 
50
75
  ## Minimal example
51
76
 
@@ -219,23 +244,53 @@ Keyword-only; every argument defaults to `None`, meaning "leave the default".
219
244
  | `wm_class` | identity's | `StartupWMClass` for window-manager grouping. |
220
245
  | `notify_app` | identity's | `notify-send` application label on the `--check` path. |
221
246
  | `autostart_check_desktop_name`, `check_log_name`, `check_state_name` | identity's | Login-check artifact filenames. |
247
+ | `hooks` | `None` | `InstallHooks(install_tool=…, remove_tool=…, install_skill=…, uninstall_skill=…)`: replace how one tool is installed, for a wrapper that builds a venv per tool. A field left `None` keeps the kit's own. |
248
+ | `upgrade_repos` | `[]` | `(name, path)` of the tool repos an upgrade may pull. `sources.run_installer` fills it with the repos it cloned. See "Upgrades" below. |
222
249
 
223
250
  The identity is applied first and these individual names override it, so you can
224
251
  take the whole namespace from a slug and still change one thing.
225
252
 
226
253
  `run()` owns its own `argparse` and consumes `sys.argv`: `--list`, `--check`,
227
- `--enable-autostart-check`, `--install`, `--update-all`, `--cleanup`, `--tui`,
228
- `--gui`, and a screen when given none of them. A wrapper that needs its own
254
+ `--enable-autostart-check`, `--install`, `--update-all`, `--upgrade`, `--cleanup`,
255
+ `--tui`, `--gui`, and a screen when given none of them. A wrapper that needs its own
229
256
  subcommands should skip `run()` and call the primitives (`discover_tools`,
230
257
  `install_tool`, `remove_tool`, `cli_check`) after applying an identity with
231
258
  `_apply_identity`.
232
259
 
260
+ ### Upgrades
261
+
262
+ When the window or the text screen opens, the installer checks in the
263
+ background whether anything it runs is out of date:
264
+
265
+ - the installer's own checkout (the git repo `entry_script` is in) is behind
266
+ its upstream;
267
+ - a tool repo in `upgrade_repos` is behind its upstream;
268
+ - pip would install a newer cli-tools-kit within the installer's pin. The pin is
269
+ the `requirements.txt` next to `entry_script` when it names the kit, else
270
+ anything below the next major version.
271
+
272
+ If something is, a strip above the table lists it with an Upgrade button (on
273
+ the text screen, a log line and the `u` key). Upgrade pulls the repos with `git
274
+ pull --ff-only`, runs `pip install --upgrade` for the kit, reinstalls the
275
+ installed tools of each pulled repo, and starts the installer again with its
276
+ original command line so the new code is loaded. `--upgrade` does the same
277
+ from a script, checking at once and without the restart.
278
+
279
+ The network is used at most once a day: one `git fetch` per repo and one `pip
280
+ install --dry-run`, cached under the identity's cache directory. The
281
+ comparison with what is on disk runs on every start, so a repo pulled by hand
282
+ stops showing at once. Git never asks for a password here; a private repo
283
+ without stored credentials is skipped. The kit is left alone when it runs from
284
+ a development checkout or outside a virtual environment. `--check` never runs
285
+ any of this, so the login check stays network-free.
286
+
233
287
  ### The text screen
234
288
 
235
289
  Without a display (`DISPLAY`/`WAYLAND_DISPLAY` unset: SSH, WSL, a server) or
236
290
  without `python3-tk`, `run()` opens a curses screen instead of the tkinter
237
291
  window; `--tui` and `--gui` force either. Same rows, same Apply: `Space` ticks
238
- Install, `s` ticks Skill, `a`/`n` tick all or none, `Enter` applies, `q` quits.
292
+ Install, `s` ticks Skill, `a`/`n` tick all or none, `Enter` applies, `u`
293
+ upgrades when an upgrade is offered, `q` quits.
239
294
  On a host where none of the tools is installed yet every row starts ticked.
240
295
 
241
296
  A skill can go to more than one place. The default target writes
@@ -323,7 +378,7 @@ and needs no network.
323
378
  Icon thumbnails need Pillow:
324
379
 
325
380
  ```bash
326
- pip install "cli-tools-kit[gui]==0.6.0"
381
+ pip install "cli-tools-kit[gui]>=1.0,<2"
327
382
  ```
328
383
 
329
384
  Installing the package also exposes a `cli-tool-installer` console script.
@@ -416,8 +471,8 @@ full rather than shallow (a tool that stamps its output with its commit needs th
416
471
  history), and nothing is cloned into a root that does not exist or cannot be
417
472
  written to. A clone that fails prints one line and that source is dropped, so a
418
473
  colleague without access to a private repo still gets everybody else's tools.
419
- `--refresh` brings the clones up to date with `git pull --ff-only`; a checkout
420
- given by `path` is never pulled. Cloning happens in the engine's `pre_discovery`
474
+ `--refresh` and the upgrade (see "Upgrades") bring the clones up to date with
475
+ `git pull --ff-only`; a checkout given by `path` is never pulled. Cloning happens in the engine's `pre_discovery`
421
476
  hook, which `--check` skips, so the login check stays network-free.
422
477
 
423
478
  An `org` entry is the only thing in this module that reaches anything but git,
@@ -475,13 +530,33 @@ per repo it could not (`log=` takes any callable, `print` by default). Pass
475
530
  Reading the TOML needs Python 3.11 or the `tomli` package, which is a dependency
476
531
  on 3.10.
477
532
 
533
+ ## Testing a tool's `--advertise`
534
+
535
+ A tool that breaks the protocol is skipped by the parent installer and simply
536
+ disappears from the list. Put this in the tool's own tests to catch that:
537
+
538
+ ```python
539
+ from cli_tools_kit.testing import assert_advertises
540
+
541
+ def test_advertise():
542
+ assert_advertises("main.py")
543
+ ```
544
+
545
+ It runs the probe the way a parent does (5 s limit, only JSON on stdout) and
546
+ fails with every problem it finds.
547
+
478
548
  ## Tests
479
549
 
480
550
  ```bash
481
- pip install -e ".[dev]"
482
- pytest
551
+ pip install -e ".[dev,gui]"
552
+ ruff check .
553
+ pytest # xvfb-run -a pytest on a host without a display
483
554
  ```
484
555
 
556
+ CI runs the same on Linux and Windows for Python 3.10 to 3.13, plus an
557
+ install from the built wheel. The GUI smoke test skips where tkinter or a
558
+ display is missing.
559
+
485
560
  ## Used by
486
561
 
487
562
  Consumers, each a self-installing tool that answers `--advertise`:
@@ -56,4 +56,4 @@ __all__ = [
56
56
  "read_installed_skill",
57
57
  ]
58
58
 
59
- __version__ = "0.8.4"
59
+ __version__ = "1.1.0"
@@ -0,0 +1,284 @@
1
+ """Starting tools at login (autostart entry or cron) and the login update check."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shlex
7
+ import stat
8
+ import subprocess
9
+ import sys
10
+ from typing import Optional
11
+
12
+ from . import discovery
13
+ from . import host
14
+ from . import state
15
+ from .autostart_gate import build_exec_prefix, load_tool_conditions, save_tool_conditions
16
+ from .cron_installer import read_crontab
17
+
18
+
19
+ def autostart_check_enabled() -> bool:
20
+ return os.path.exists(state.AUTOSTART_CHECK_DESKTOP)
21
+
22
+
23
+ def enable_autostart_check() -> str:
24
+ """Write the login update-check autostart entry. Returns its path."""
25
+ os.makedirs(state.AUTOSTART_DIR, exist_ok=True)
26
+
27
+ if host.IS_WINDOWS:
28
+ # The Startup folder runs shortcuts; a .desktop file dropped there is
29
+ # never executed. pythonw keeps the console window from flashing up at
30
+ # every login, since the check reports through a notification anyway.
31
+ runner = sys.executable
32
+ windowless = os.path.join(os.path.dirname(runner), "pythonw.exe")
33
+ if os.path.exists(windowless):
34
+ runner = windowless
35
+ host.write_shortcut(
36
+ state.AUTOSTART_CHECK_DESKTOP,
37
+ target=runner,
38
+ args=f'"{state.ENTRY_SCRIPT}" --check',
39
+ workdir=os.path.dirname(state.ENTRY_SCRIPT),
40
+ )
41
+ return state.AUTOSTART_CHECK_DESKTOP
42
+
43
+ exec_line = f"{sys.executable} {state.ENTRY_SCRIPT} --check"
44
+ content = (
45
+ "[Desktop Entry]\n"
46
+ "Type=Application\n"
47
+ f"Name={state.SELF_DESKTOP_NAME} — login update check\n"
48
+ "Comment=Apply network-free tool reconciliations on login; notify for new tools\n"
49
+ f"Exec={exec_line}\n"
50
+ "Icon=system-software-update\n"
51
+ "Terminal=false\n"
52
+ "NoDisplay=true\n"
53
+ "X-KDE-autostart-after=panel\n"
54
+ "X-GNOME-Autostart-enabled=true\n"
55
+ )
56
+ with open(state.AUTOSTART_CHECK_DESKTOP, "w") as f:
57
+ f.write(content)
58
+ return state.AUTOSTART_CHECK_DESKTOP
59
+
60
+
61
+ def disable_autostart_check() -> bool:
62
+ """Remove the login update-check autostart entry. True if one was present."""
63
+ removed = False
64
+ # On Windows, also clear the .desktop an older version wrote into the
65
+ # Startup folder, where it sat inert instead of running the check.
66
+ stale = os.path.join(state.AUTOSTART_DIR, state.AUTOSTART_CHECK_DESKTOP_NAME)
67
+ for path in {state.AUTOSTART_CHECK_DESKTOP, stale}:
68
+ if os.path.exists(path):
69
+ os.remove(path)
70
+ removed = True
71
+ return removed
72
+
73
+
74
+ # ================= AUTOSTART UTILITIES =================
75
+
76
+ def get_autostart_path(tool: discovery.ToolEntry) -> str:
77
+ """Where a tool's autostart entry lives: a .desktop symlink in
78
+ ~/.config/autostart, or a copy of its .lnk in the Startup folder."""
79
+ if host.IS_WINDOWS:
80
+ return os.path.join(state.AUTOSTART_DIR,
81
+ os.path.splitext(tool.desktop_file)[0] + ".lnk")
82
+ return os.path.join(state.AUTOSTART_DIR, tool.desktop_file)
83
+
84
+
85
+ def _cron_line_for_tool(tool: discovery.ToolEntry) -> str:
86
+ """Build the crontab line for a cron-based tool."""
87
+ parts = [tool.cron_schedule, sys.executable, tool.script_path] + list(tool.cron_args)
88
+ return " ".join(parts)
89
+
90
+
91
+ def _cron_contains(line: str) -> bool:
92
+ try:
93
+ result = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
94
+ return result.returncode == 0 and line in result.stdout
95
+ except Exception:
96
+ return False
97
+
98
+
99
+ def is_autostart_enabled(tool: discovery.ToolEntry) -> bool:
100
+ """Check if autostart is enabled for a tool."""
101
+ if "Icon" in tool.tags:
102
+ return os.path.exists(get_autostart_path(tool))
103
+ if tool.cron_schedule:
104
+ return _cron_contains(_cron_line_for_tool(tool))
105
+ return False
106
+
107
+
108
+ def autostart_tool_key(tool: discovery.ToolEntry) -> str:
109
+ """The key a tool's conditions are stored under.
110
+
111
+ The .desktop stem: stable across renames of the display name, and already
112
+ unique per installed shortcut.
113
+ """
114
+ return os.path.splitext(tool.desktop_file)[0]
115
+
116
+
117
+ def get_autostart_conditions(tool: discovery.ToolEntry) -> dict:
118
+ """The conditions currently configured for *tool* on this host."""
119
+ return load_tool_conditions(state.IDENTITY.slug, autostart_tool_key(tool))
120
+
121
+
122
+ def set_autostart_conditions(tool: discovery.ToolEntry, conditions: Optional[dict]) -> None:
123
+ """Store *tool*'s conditions, then rewrite its entry if autostart is on.
124
+
125
+ The Exec line differs between a gated and an ungated entry, so a change
126
+ here only takes effect once the entry is rewritten.
127
+ """
128
+ save_tool_conditions(state.IDENTITY.slug, autostart_tool_key(tool), conditions)
129
+ if is_autostart_enabled(tool):
130
+ enable_autostart(tool)
131
+
132
+
133
+ def _read_desktop_exec(desktop_path: str) -> str:
134
+ """The Exec= line of an installed .desktop, or "" if it has none."""
135
+ try:
136
+ with open(desktop_path, "r", encoding="utf-8") as fh:
137
+ for line in fh:
138
+ if line.startswith("Exec="):
139
+ return line[len("Exec="):].strip()
140
+ except OSError:
141
+ pass
142
+ return ""
143
+
144
+
145
+ def _write_gated_autostart(tool: discovery.ToolEntry, desktop_path: str,
146
+ autostart_path: str, conditions: dict) -> tuple[bool, str]:
147
+ """Write an autostart .desktop whose Exec runs *tool* through the gate.
148
+
149
+ A real file rather than the usual symlink: the app entry in the menu must
150
+ keep launching the tool unconditionally, so only this copy carries the
151
+ gate. Everything else is inherited from the installed entry.
152
+ """
153
+ exec_line = _read_desktop_exec(desktop_path)
154
+ if not exec_line:
155
+ return False, f"No Exec line in {desktop_path}"
156
+
157
+ prefix = " ".join(shlex.quote(p) for p in
158
+ build_exec_prefix(state.IDENTITY.slug, autostart_tool_key(tool)))
159
+ gated_exec = f"{prefix} {exec_line}"
160
+
161
+ try:
162
+ with open(desktop_path, "r", encoding="utf-8") as fh:
163
+ lines = fh.read().splitlines()
164
+ except OSError as e:
165
+ return False, f"Failed to read {desktop_path}: {e}"
166
+
167
+ out = []
168
+ for line in lines:
169
+ if line.startswith("Exec="):
170
+ out.append(f"Exec={gated_exec}")
171
+ elif line.startswith("X-CliToolsKit-Gated="):
172
+ continue
173
+ else:
174
+ out.append(line)
175
+ # Marks the entry as ours and generated, so it is obvious in a diff why
176
+ # this one is a file where every other autostart entry is a symlink.
177
+ out.append("X-CliToolsKit-Gated=true")
178
+
179
+ try:
180
+ if os.path.exists(autostart_path) or os.path.islink(autostart_path):
181
+ os.remove(autostart_path)
182
+ with open(autostart_path, "w", encoding="utf-8") as fh:
183
+ fh.write("\n".join(out) + "\n")
184
+ # Deliberately not executable: systemd-xdg-autostart-generator warns
185
+ # on every login about an executable entry in ~/.config/autostart.
186
+ exec_bits = stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
187
+ os.chmod(autostart_path, os.stat(autostart_path).st_mode & ~exec_bits)
188
+ except OSError as e:
189
+ return False, f"Failed to write {autostart_path}: {e}"
190
+
191
+ return True, f"Autostart enabled (conditional): {tool.name}"
192
+
193
+
194
+ def enable_autostart(tool: discovery.ToolEntry) -> tuple[bool, str]:
195
+ """Enable autostart for a tool.
196
+
197
+ Icon tools: create a .desktop symlink in ~/.config/autostart.
198
+ Cron tools: add an @reboot (or other schedule) crontab entry.
199
+
200
+ Returns (success, message).
201
+ """
202
+ if "Icon" in tool.tags:
203
+ if host.IS_WINDOWS:
204
+ stem = os.path.splitext(tool.desktop_file)[0]
205
+ lnk_path = os.path.join(state.APPS_DIR, stem + ".lnk")
206
+ if not os.path.exists(lnk_path):
207
+ return False, f"Shortcut not found: {lnk_path}"
208
+ os.makedirs(state.AUTOSTART_DIR, exist_ok=True)
209
+ try:
210
+ import shutil
211
+ shutil.copyfile(lnk_path, get_autostart_path(tool))
212
+ return True, f"Autostart enabled: {tool.name}"
213
+ except OSError as e:
214
+ return False, f"Failed to copy shortcut: {e}"
215
+
216
+ desktop_path = os.path.join(state.APPS_DIR, tool.desktop_file)
217
+ if not os.path.exists(desktop_path):
218
+ return False, f"Desktop file not found: {desktop_path}"
219
+
220
+ os.makedirs(state.AUTOSTART_DIR, exist_ok=True)
221
+ autostart_path = get_autostart_path(tool)
222
+
223
+ # A tool with conditions configured gets a gated copy instead of the
224
+ # plain symlink, so the menu entry stays unconditional.
225
+ conditions = get_autostart_conditions(tool)
226
+ if conditions:
227
+ return _write_gated_autostart(tool, desktop_path, autostart_path, conditions)
228
+
229
+ if os.path.exists(autostart_path) or os.path.islink(autostart_path):
230
+ os.remove(autostart_path)
231
+
232
+ try:
233
+ os.symlink(desktop_path, autostart_path)
234
+ return True, f"Autostart enabled: {tool.name}"
235
+ except OSError as e:
236
+ return False, f"Failed to create symlink: {e}"
237
+
238
+ if tool.cron_schedule:
239
+ if host.IS_WINDOWS:
240
+ return False, "Cron autostart is not supported on Windows"
241
+ line = _cron_line_for_tool(tool)
242
+ try:
243
+ existing = read_crontab() # raises rather than read a failure as empty
244
+ if line in existing:
245
+ return True, "Cron entry already present"
246
+ new_crontab = existing.rstrip("\n") + ("\n" if existing else "") + line + "\n"
247
+ subprocess.run(["crontab", "-"], input=new_crontab, text=True, check=True)
248
+ return True, f"Cron entry added: {line}"
249
+ except Exception as e:
250
+ return False, f"Failed to add cron entry: {e}"
251
+
252
+ return False, "Tool has no supported autostart method"
253
+
254
+
255
+ def disable_autostart(tool: discovery.ToolEntry) -> tuple[bool, str]:
256
+ """Disable autostart for a tool.
257
+
258
+ Returns (success, message).
259
+ """
260
+ if "Icon" in tool.tags:
261
+ autostart_path = get_autostart_path(tool)
262
+ if not os.path.exists(autostart_path) and not os.path.islink(autostart_path):
263
+ return True, "Already disabled"
264
+ try:
265
+ os.remove(autostart_path)
266
+ return True, f"Autostart disabled: {tool.name}"
267
+ except OSError as e:
268
+ return False, f"Failed to remove {'shortcut' if host.IS_WINDOWS else 'symlink'}: {e}"
269
+
270
+ if tool.cron_schedule:
271
+ if host.IS_WINDOWS:
272
+ return False, "Cron autostart is not supported on Windows"
273
+ line = _cron_line_for_tool(tool)
274
+ try:
275
+ existing = read_crontab()
276
+ if line not in existing:
277
+ return True, "Already disabled"
278
+ new_crontab = "\n".join(l for l in existing.splitlines() if l != line) + "\n"
279
+ subprocess.run(["crontab", "-"], input=new_crontab, text=True, check=True)
280
+ return True, f"Cron entry removed: {tool.name}"
281
+ except Exception as e:
282
+ return False, f"Failed to remove cron entry: {e}"
283
+
284
+ return True, "Already disabled"