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.
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/PKG-INFO +93 -15
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/README.md +88 -13
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/__init__.py +1 -1
- cli_tools_kit-1.1.0/cli_tools_kit/autostart.py +284 -0
- cli_tools_kit-1.1.0/cli_tools_kit/cli.py +663 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/cron_installer.py +41 -23
- cli_tools_kit-1.1.0/cli_tools_kit/discovery.py +294 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/gui_installer.py +381 -2447
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/host.py +68 -1
- cli_tools_kit-1.1.0/cli_tools_kit/icons.py +423 -0
- cli_tools_kit-1.1.0/cli_tools_kit/install.py +298 -0
- cli_tools_kit-1.1.0/cli_tools_kit/settings.py +125 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/sources.py +37 -10
- cli_tools_kit-1.1.0/cli_tools_kit/state.py +204 -0
- cli_tools_kit-1.1.0/cli_tools_kit/sweep.py +187 -0
- cli_tools_kit-1.1.0/cli_tools_kit/testing.py +126 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/tool_installer.py +10 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/tui_installer.py +55 -2
- cli_tools_kit-1.1.0/cli_tools_kit/upgrade.py +312 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit.egg-info/PKG-INFO +93 -15
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit.egg-info/SOURCES.txt +18 -1
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/pyproject.toml +10 -3
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/tests/test_cron_installer.py +23 -0
- cli_tools_kit-1.1.0/tests/test_e2e.py +164 -0
- cli_tools_kit-1.1.0/tests/test_engine.py +182 -0
- cli_tools_kit-1.1.0/tests/test_gui_smoke.py +68 -0
- cli_tools_kit-1.1.0/tests/test_hooks.py +45 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/tests/test_host.py +42 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/tests/test_identity.py +22 -20
- cli_tools_kit-1.1.0/tests/test_public_api.py +117 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/tests/test_sources.py +42 -17
- cli_tools_kit-1.1.0/tests/test_testing.py +57 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/tests/test_tool_installer.py +22 -1
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/tests/test_tui_installer.py +21 -2
- cli_tools_kit-1.1.0/tests/test_upgrade.py +196 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/LICENSE +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/__main__.py +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/advertise.py +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/autostart_gate.py +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/identity.py +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/onboarding.py +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/skills.py +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/taxonomy/__init__.py +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/taxonomy/build.py +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/taxonomy/capability.py +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/taxonomy/cluster.py +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/taxonomy/corpus.py +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/taxonomy/embedder.py +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/taxonomy/groups.py +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit/taxonomy/llm_groups.py +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit.egg-info/dependency_links.txt +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit.egg-info/entry_points.txt +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit.egg-info/requires.txt +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/cli_tools_kit.egg-info/top_level.txt +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/setup.cfg +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/tests/test_autostart_gate.py +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/tests/test_capability_groups.py +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/tests/test_llm_groups.py +0 -0
- {cli_tools_kit-0.8.4 → cli_tools_kit-1.1.0}/tests/test_skills.py +0 -0
- {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:
|
|
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
|
|
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@
|
|
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
|
-
|
|
105
|
-
|
|
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`, `--
|
|
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, `
|
|
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]
|
|
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`
|
|
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
|
-
|
|
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
|
|
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@
|
|
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
|
-
|
|
48
|
-
|
|
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`, `--
|
|
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, `
|
|
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]
|
|
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`
|
|
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
|
-
|
|
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`:
|
|
@@ -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"
|